@@ -12,10 +12,11 @@ import { Form, Outlet, useLocation, useNavigate, useNavigation, useParams } from |
12 | 12 |
|
13 | 13 | import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; |
14 | 14 | import { CogIcon, GitBranchIcon } from "lucide-react"; |
15 | | -import { useEffect } from "react"; |
| 15 | +import { useEffect, useRef, useState } from "react"; |
16 | 16 | import { typedjson, useTypedLoaderData } from "remix-typedjson"; |
17 | 17 | import { z } from "zod"; |
18 | 18 | import { PromoteIcon } from "~/assets/icons/PromoteIcon"; |
| 19 | +import { InlineCode } from "~/components/code/InlineCode"; |
19 | 20 | import { VercelLink } from "~/components/integrations/VercelLink"; |
20 | 21 | import { DeploymentsNone, DeploymentsNoneDev } from "~/components/BlankStatePanels"; |
21 | 22 | import { OctoKitty } from "~/components/GitHubLoginButton"; |
@@ -25,6 +26,7 @@ import { UserAvatar } from "~/components/UserProfilePhoto"; |
25 | 26 | import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; |
26 | 27 | import { Badge } from "~/components/primitives/Badge"; |
27 | 28 | import { Button, LinkButton } from "~/components/primitives/Buttons"; |
| 29 | +import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; |
28 | 30 | import { DateTime } from "~/components/primitives/DateTime"; |
29 | 31 | import { SpinnerWhite } from "~/components/primitives/Spinner"; |
30 | 32 | import { |
@@ -154,6 +156,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { |
154 | 156 | canWriteDeployments: { action: "write", resource: { type: "deployments" } }, |
155 | 157 | }).canWriteDeployments |
156 | 158 | : true; |
| 159 | + const redeployCutoff = new Date( |
| 160 | + Date.now() - env.DEPLOYMENTS_REDEPLOY_WINDOW_DAYS * 24 * 60 * 60 * 1000 |
| 161 | + ); |
157 | 162 |
|
158 | 163 | const onboarding = await resolveDeploymentOnboardingUi({ |
159 | 164 | request, |
@@ -173,6 +178,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { |
173 | 178 | ...result, |
174 | 179 | ...onboarding, |
175 | 180 | canWriteDeployments, |
| 181 | + redeployCutoff, |
176 | 182 | selectedDeployment, |
177 | 183 | autoReloadPollIntervalMs, |
178 | 184 | }); |
@@ -199,6 +205,7 @@ export default function Page() { |
199 | 205 | autoReloadPollIntervalMs, |
200 | 206 | hasVercelIntegration, |
201 | 207 | canWriteDeployments, |
| 208 | + redeployCutoff, |
202 | 209 | canDeployNow, |
203 | 210 | deployNowEnabled, |
204 | 211 | isPlatformConfigured, |
@@ -409,6 +416,7 @@ export default function Page() { |
409 | 416 | isSelected={isSelected} |
410 | 417 | currentDeployment={currentDeployment} |
411 | 418 | canWriteDeployments={canWriteDeployments} |
| 419 | + redeployCutoff={redeployCutoff} |
412 | 420 | /> |
413 | 421 | </TableRow> |
414 | 422 | ); |
@@ -531,12 +539,14 @@ function DeploymentActionsCell({ |
531 | 539 | isSelected, |
532 | 540 | currentDeployment, |
533 | 541 | canWriteDeployments, |
| 542 | + redeployCutoff, |
534 | 543 | }: { |
535 | 544 | deployment: DeploymentListItem; |
536 | 545 | path: string; |
537 | 546 | isSelected: boolean; |
538 | 547 | currentDeployment?: DeploymentListItem; |
539 | 548 | canWriteDeployments: boolean; |
| 549 | + redeployCutoff: Date; |
540 | 550 | }) { |
541 | 551 | const location = useLocation(); |
542 | 552 | const project = useProject(); |
@@ -550,8 +560,15 @@ function DeploymentActionsCell({ |
550 | 560 |
|
551 | 561 | const finalStatuses = ["CANCELED", "DEPLOYED", "FAILED", "TIMED_OUT"]; |
552 | 562 | const canBeCanceled = !finalStatuses.includes(deployment.status); |
553 | | - |
554 | | - if (!canBeRolledBack && !canBePromoted && !canBeCanceled) { |
| 563 | + const showRedeploy = |
| 564 | + deployment.redeploySource !== null && |
| 565 | + finalStatuses.includes(deployment.status) && |
| 566 | + deployment.createdAt >= redeployCutoff; |
| 567 | + const redeployDisabledReason = canWriteDeployments |
| 568 | + ? undefined |
| 569 | + : "You don't have permission to redeploy"; |
| 570 | + |
| 571 | + if (!canBeRolledBack && !canBePromoted && !canBeCanceled && !showRedeploy) { |
555 | 572 | return ( |
556 | 573 | <TableCell to={path} isSelected={isSelected}> |
557 | 574 | {""} |
@@ -563,7 +580,8 @@ function DeploymentActionsCell({ |
563 | 580 | <TableCellMenu |
564 | 581 | isSticky |
565 | 582 | isSelected={isSelected} |
566 | | - popoverContent={ |
| 583 | + // oxlint-disable-next-line react/no-unstable-nested-components -- Render prop: the menu primitive passes its close handler so the redeploy dialog can dismiss the menu. |
| 584 | + popoverContent={(closeMenu) => ( |
567 | 585 | <> |
568 | 586 | {canBeRolledBack && |
569 | 587 | (canWriteDeployments ? ( |
@@ -664,12 +682,122 @@ function DeploymentActionsCell({ |
664 | 682 | Cancel |
665 | 683 | </Button> |
666 | 684 | ))} |
| 685 | + {showRedeploy && |
| 686 | + (redeployDisabledReason === undefined ? ( |
| 687 | + <RedeployDeploymentDialog |
| 688 | + projectId={project.id} |
| 689 | + deployment={deployment} |
| 690 | + redirectPath={`${location.pathname}${location.search}`} |
| 691 | + onSettled={closeMenu} |
| 692 | + /> |
| 693 | + ) : ( |
| 694 | + <Button |
| 695 | + variant="small-menu-item" |
| 696 | + LeadingIcon={ArrowPathIcon} |
| 697 | + leadingIconClassName="text-blue-500" |
| 698 | + fullWidth |
| 699 | + textAlignLeft |
| 700 | + disabled |
| 701 | + tooltip={redeployDisabledReason} |
| 702 | + > |
| 703 | + Redeploy |
| 704 | + </Button> |
| 705 | + ))} |
667 | 706 | </> |
668 | | - } |
| 707 | + )} |
669 | 708 | /> |
670 | 709 | ); |
671 | 710 | } |
672 | 711 |
|
| 712 | +function RedeployDeploymentDialog({ |
| 713 | + projectId, |
| 714 | + deployment, |
| 715 | + redirectPath, |
| 716 | + onSettled, |
| 717 | +}: { |
| 718 | + projectId: string; |
| 719 | + deployment: DeploymentListItem; |
| 720 | + redirectPath: string; |
| 721 | + onSettled: () => void; |
| 722 | +}) { |
| 723 | + const navigation = useNavigation(); |
| 724 | + const [open, setOpen] = useState(false); |
| 725 | + |
| 726 | + const formAction = `/resources/${projectId}/deployments/${deployment.shortCode}/redeploy`; |
| 727 | + const formId = `redeploy-${deployment.shortCode}`; |
| 728 | + const isLoading = navigation.formAction === formAction; |
| 729 | + |
| 730 | + // The source row stays eligible after a redeploy, so the dialog never unmounts; close it |
| 731 | + // once the submission has settled (the toast carries the outcome). |
| 732 | + const wasLoading = useRef(false); |
| 733 | + useEffect(() => { |
| 734 | + if (wasLoading.current && !isLoading) { |
| 735 | + setOpen(false); |
| 736 | + onSettled(); |
| 737 | + } |
| 738 | + wasLoading.current = isLoading; |
| 739 | + }, [isLoading, onSettled]); |
| 740 | + |
| 741 | + return ( |
| 742 | + <Dialog open={open} onOpenChange={setOpen}> |
| 743 | + <DialogTrigger asChild> |
| 744 | + <Button |
| 745 | + variant="small-menu-item" |
| 746 | + LeadingIcon={ArrowPathIcon} |
| 747 | + leadingIconClassName="text-blue-500" |
| 748 | + fullWidth |
| 749 | + textAlignLeft |
| 750 | + > |
| 751 | + Redeploy |
| 752 | + </Button> |
| 753 | + </DialogTrigger> |
| 754 | + <DialogContent key="redeploy"> |
| 755 | + <DialogHeader>Redeploy</DialogHeader> |
| 756 | + <DialogDescription> |
| 757 | + {deployment.redeploySource === "github" && deployment.git ? ( |
| 758 | + <> |
| 759 | + A new deployment will rebuild commit{" "} |
| 760 | + <InlineCode variant="extra-small">{deployment.git.shortSha}</InlineCode> from branch{" "} |
| 761 | + <InlineCode variant="extra-small">{deployment.git.branchName}</InlineCode>. |
| 762 | + </> |
| 763 | + ) : ( |
| 764 | + <> |
| 765 | + A new deployment will be created from the source files of deployment{" "} |
| 766 | + <InlineCode variant="extra-small">{deployment.shortCode}</InlineCode>. |
| 767 | + </> |
| 768 | + )} |
| 769 | + </DialogDescription> |
| 770 | + <CheckboxWithLabel |
| 771 | + name="promote" |
| 772 | + value="on" |
| 773 | + form={formId} |
| 774 | + variant="simple/small" |
| 775 | + defaultChecked |
| 776 | + label="Promote deployment automatically" |
| 777 | + /> |
| 778 | + <DialogFooter> |
| 779 | + <DialogClose asChild> |
| 780 | + <Button variant="tertiary/medium">Cancel</Button> |
| 781 | + </DialogClose> |
| 782 | + <Form id={formId} action={formAction} method="post"> |
| 783 | + <Button |
| 784 | + type="submit" |
| 785 | + name="redirectUrl" |
| 786 | + value={redirectPath} |
| 787 | + variant="primary/medium" |
| 788 | + LeadingIcon={isLoading ? SpinnerWhite : ArrowPathIcon} |
| 789 | + disabled={isLoading} |
| 790 | + shortcut={{ modifiers: ["mod"], key: "enter" }} |
| 791 | + > |
| 792 | + {isLoading ? "Redeploying..." : "Redeploy"} |
| 793 | + </Button> |
| 794 | + </Form> |
| 795 | + </DialogFooter> |
| 796 | + </DialogContent> |
| 797 | + </Dialog> |
| 798 | + ); |
| 799 | +} |
| 800 | + |
673 | 801 | function DeploymentExternalIdCell({ |
674 | 802 | externalId, |
675 | 803 | path, |
|