Skip to content

Commit bdb2f35

Browse files
myftijaTrigger.dev RepoOps
authored andcommitted
feat(webapp): redeploy recent deployments from the dashboard
Add a Redeploy action to the deployments list. Finished deployments from the last seven days that were triggered from GitHub or deployed with the CLI's native build can be rebuilt from the dashboard as a new deployment, with an option to promote it automatically once it succeeds. The new deployment links back to the one it was redeployed from. Mono-RevId: c378a6f30e5e6ff952e3f084f8426e6a5d342d62
1 parent 8178ba7 commit bdb2f35

16 files changed

Lines changed: 1156 additions & 9 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Recent deployments started from the GitHub integration or a native CLI deploy can be redeployed from the deployments list, rebuilding the same commit or upload as a new deployment.

‎apps/webapp/app/env.server.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2026,6 +2026,7 @@ const EnvironmentSchema = z
20262026
TASK_EVENT_PARTITIONED_WINDOW_IN_SECONDS: z.coerce.number().int().default(60), // 1 minute
20272027

20282028
DEPLOYMENTS_AUTORELOAD_POLL_INTERVAL_MS: z.coerce.number().int().default(5_000),
2029+
DEPLOYMENTS_REDEPLOY_WINDOW_DAYS: z.coerce.number().int().positive().default(7),
20292030
BULK_ACTION_AUTORELOAD_POLL_INTERVAL_MS: z.coerce.number().int().default(1_000),
20302031
QUEUES_AUTORELOAD_POLL_INTERVAL_MS: z.coerce.number().int().default(5_000),
20312032

‎apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
VercelProjectIntegrationDataSchema,
1515
buildVercelDeploymentUrl,
1616
} from "~/v3/vercel/vercelProjectIntegrationSchema";
17+
import { resolveRedeploySource } from "~/v3/redeploy";
1718

1819
const pageSize = 20;
1920

@@ -154,7 +155,10 @@ export class DeploymentListPresenter {
154155
userAvatarUrl: string | null;
155156
type: WorkerInstanceGroupType;
156157
externalId: string | null;
158+
triggeredVia: string | null;
157159
git: Prisma.JsonValue | null;
160+
buildServerMetadata: Prisma.JsonValue | null;
161+
createdAt: Date;
158162
integrationDeploymentId: string | null;
159163
}[]
160164
>`
@@ -175,7 +179,10 @@ export class DeploymentListPresenter {
175179
wd."deployedAt",
176180
wd."type",
177181
wd."externalId",
178-
wd."git"
182+
wd."triggeredVia",
183+
wd."git",
184+
wd."buildServerMetadata",
185+
wd."createdAt"
179186
${vercelSelect}
180187
FROM
181188
${sqlDatabaseSchema}."WorkerDeployment" as wd
@@ -267,7 +274,10 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
267274
}
268275
: undefined,
269276
externalId: deployment.externalId,
277+
triggeredVia: deployment.triggeredVia,
270278
git: processGitMetadata(deployment.git),
279+
createdAt: deployment.createdAt,
280+
redeploySource: resolveRedeploySource(deployment)?.kind ?? null,
271281
vercelDeploymentUrl,
272282
};
273283
}),

‎apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { env } from "~/env.server";
1818
import { createRedisClient } from "~/redis.server";
1919
import { tryCatch } from "@trigger.dev/core";
2020
import { logger } from "~/services/logger.server";
21+
import { redeployOfShortCode } from "~/v3/redeploy";
2122

2223
const S2_TOKEN_KEY_PREFIX = "s2-token:project:";
2324

@@ -278,6 +279,7 @@ export class DeploymentPresenter {
278279
externalId: deployment.externalId,
279280
git: gitMetadata,
280281
triggeredVia: deployment.triggeredVia,
282+
redeployOf: redeployOfShortCode(deployment.buildServerMetadata),
281283
vercelDeploymentUrl,
282284
},
283285
};

‎apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,12 @@ import { useProject } from "~/hooks/useProject";
4949
import { DeploymentPresenter } from "~/presenters/v3/DeploymentPresenter.server";
5050
import { requireUserId } from "~/services/session.server";
5151
import { cn } from "~/utils/cn";
52-
import { v3DeploymentParams, v3DeploymentsPath, v3RunsPath } from "~/utils/pathBuilder";
52+
import {
53+
v3DeploymentParams,
54+
v3DeploymentPath,
55+
v3DeploymentsPath,
56+
v3RunsPath,
57+
} from "~/utils/pathBuilder";
5358
import { capitalizeWord } from "~/utils/string";
5459
import { UserTag } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route";
5560
import { useDeploymentLogs } from "~/hooks/useDeploymentLogs";
@@ -296,6 +301,24 @@ export default function Page() {
296301
<Property.Label>Version</Property.Label>
297302
<Property.Value>{deployment.version}</Property.Value>
298303
</Property.Item>
304+
{deployment.redeployOf && (
305+
<Property.Item>
306+
<Property.Label>Redeploy of</Property.Label>
307+
<Property.Value>
308+
<TextLink
309+
to={v3DeploymentPath(
310+
organization,
311+
project,
312+
environment,
313+
{ shortCode: deployment.redeployOf },
314+
0
315+
)}
316+
>
317+
{deployment.redeployOf}
318+
</TextLink>
319+
</Property.Value>
320+
</Property.Item>
321+
)}
299322
<Property.Item>
300323
<Property.Label>Status</Property.Label>
301324
<Property.Value>

‎apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx‎

Lines changed: 133 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ import { Form, Outlet, useLocation, useNavigate, useNavigation, useParams } from
1212

1313
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
1414
import { CogIcon, GitBranchIcon } from "lucide-react";
15-
import { useEffect } from "react";
15+
import { useEffect, useRef, useState } from "react";
1616
import { typedjson, useTypedLoaderData } from "remix-typedjson";
1717
import { z } from "zod";
1818
import { PromoteIcon } from "~/assets/icons/PromoteIcon";
19+
import { InlineCode } from "~/components/code/InlineCode";
1920
import { VercelLink } from "~/components/integrations/VercelLink";
2021
import { DeploymentsNone, DeploymentsNoneDev } from "~/components/BlankStatePanels";
2122
import { OctoKitty } from "~/components/GitHubLoginButton";
@@ -25,6 +26,7 @@ import { UserAvatar } from "~/components/UserProfilePhoto";
2526
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
2627
import { Badge } from "~/components/primitives/Badge";
2728
import { Button, LinkButton } from "~/components/primitives/Buttons";
29+
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
2830
import { DateTime } from "~/components/primitives/DateTime";
2931
import { SpinnerWhite } from "~/components/primitives/Spinner";
3032
import {
@@ -154,6 +156,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
154156
canWriteDeployments: { action: "write", resource: { type: "deployments" } },
155157
}).canWriteDeployments
156158
: true;
159+
const redeployCutoff = new Date(
160+
Date.now() - env.DEPLOYMENTS_REDEPLOY_WINDOW_DAYS * 24 * 60 * 60 * 1000
161+
);
157162

158163
const onboarding = await resolveDeploymentOnboardingUi({
159164
request,
@@ -173,6 +178,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
173178
...result,
174179
...onboarding,
175180
canWriteDeployments,
181+
redeployCutoff,
176182
selectedDeployment,
177183
autoReloadPollIntervalMs,
178184
});
@@ -199,6 +205,7 @@ export default function Page() {
199205
autoReloadPollIntervalMs,
200206
hasVercelIntegration,
201207
canWriteDeployments,
208+
redeployCutoff,
202209
canDeployNow,
203210
deployNowEnabled,
204211
isPlatformConfigured,
@@ -409,6 +416,7 @@ export default function Page() {
409416
isSelected={isSelected}
410417
currentDeployment={currentDeployment}
411418
canWriteDeployments={canWriteDeployments}
419+
redeployCutoff={redeployCutoff}
412420
/>
413421
</TableRow>
414422
);
@@ -531,12 +539,14 @@ function DeploymentActionsCell({
531539
isSelected,
532540
currentDeployment,
533541
canWriteDeployments,
542+
redeployCutoff,
534543
}: {
535544
deployment: DeploymentListItem;
536545
path: string;
537546
isSelected: boolean;
538547
currentDeployment?: DeploymentListItem;
539548
canWriteDeployments: boolean;
549+
redeployCutoff: Date;
540550
}) {
541551
const location = useLocation();
542552
const project = useProject();
@@ -550,8 +560,15 @@ function DeploymentActionsCell({
550560

551561
const finalStatuses = ["CANCELED", "DEPLOYED", "FAILED", "TIMED_OUT"];
552562
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) {
555572
return (
556573
<TableCell to={path} isSelected={isSelected}>
557574
{""}
@@ -563,7 +580,8 @@ function DeploymentActionsCell({
563580
<TableCellMenu
564581
isSticky
565582
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) => (
567585
<>
568586
{canBeRolledBack &&
569587
(canWriteDeployments ? (
@@ -664,12 +682,122 @@ function DeploymentActionsCell({
664682
Cancel
665683
</Button>
666684
))}
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+
))}
667706
</>
668-
}
707+
)}
669708
/>
670709
);
671710
}
672711

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+
673801
function DeploymentExternalIdCell({
674802
externalId,
675803
path,

0 commit comments

Comments
 (0)