Skip to content

Commit a5d8e45

Browse files
authored
v3: deploy rollbacks (#1154)
* add deployment rollbacks * update test presenter * update task list presenter * fix test page for dev env * read replica for the test presenter
1 parent c332519 commit a5d8e45

File tree

8 files changed

+309
-48
lines changed

8 files changed

+309
-48
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { ArrowPathIcon } from "@heroicons/react/20/solid";
2+
import { Form, useNavigation } from "@remix-run/react";
3+
import { Button } from "~/components/primitives/Buttons";
4+
import {
5+
DialogContent,
6+
DialogDescription,
7+
DialogFooter,
8+
DialogHeader,
9+
} from "~/components/primitives/Dialog";
10+
11+
type RollbackDeploymentDialogProps = {
12+
projectId: string;
13+
deploymentShortCode: string;
14+
redirectPath: string;
15+
};
16+
17+
export function RollbackDeploymentDialog({
18+
projectId,
19+
deploymentShortCode,
20+
redirectPath,
21+
}: RollbackDeploymentDialogProps) {
22+
const navigation = useNavigation();
23+
24+
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/rollback`;
25+
const isLoading = navigation.formAction === formAction;
26+
27+
return (
28+
<DialogContent key="rollback">
29+
<DialogHeader>Roll back to this deployment?</DialogHeader>
30+
<DialogDescription>
31+
This deployment will become the default for all future runs. Tasks triggered but not
32+
included in this deploy will remain queued until you roll back to or create a new deployment
33+
with these tasks included.
34+
</DialogDescription>
35+
<DialogFooter>
36+
<Form
37+
action={`/resources/${projectId}/deployments/${deploymentShortCode}/rollback`}
38+
method="post"
39+
>
40+
<Button
41+
type="submit"
42+
name="redirectUrl"
43+
value={redirectPath}
44+
variant="primary/small"
45+
LeadingIcon={isLoading ? "spinner-white" : ArrowPathIcon}
46+
disabled={isLoading}
47+
shortcut={{ modifiers: ["meta"], key: "enter" }}
48+
>
49+
{isLoading ? "Rolling back..." : "Roll back deployment"}
50+
</Button>
51+
</Form>
52+
</DialogFooter>
53+
</DialogContent>
54+
);
55+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import { getUsername } from "~/utils/username";
77

88
const pageSize = 20;
99

10+
export type DeploymentList = Awaited<ReturnType<DeploymentListPresenter["call"]>>;
11+
export type DeploymentListItem = DeploymentList["deployments"][0];
12+
1013
export class DeploymentListPresenter {
1114
#prismaClient: PrismaClient;
1215

@@ -136,6 +139,8 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
136139
deployedAt: deployment.deployedAt,
137140
tasksCount: deployment.tasksCount ? Number(deployment.tasksCount) : null,
138141
label: label?.label,
142+
isCurrent: label?.label === "current",
143+
isDeployed: deployment.status === "DEPLOYED",
139144
environment: {
140145
id: environment.id,
141146
type: environment.type,

apps/webapp/app/presenters/v3/TaskListPresenter.server.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,16 @@ import type { Organization } from "~/models/organization.server";
1010
import type { Project } from "~/models/project.server";
1111
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
1212
import type { User } from "~/models/user.server";
13-
import { filterOrphanedEnvironments, sortEnvironments } from "~/utils/environmentSort";
13+
import {
14+
filterOrphanedEnvironments,
15+
onlyDevEnvironments,
16+
exceptDevEnvironments,
17+
sortEnvironments,
18+
} from "~/utils/environmentSort";
1419
import { logger } from "~/services/logger.server";
1520
import { BasePresenter } from "./basePresenter.server";
1621
import { TaskRunStatus } from "~/database-types";
22+
import { CURRENT_DEPLOYMENT_LABEL } from "~/consts";
1723

1824
export type Task = {
1925
slug: string;
@@ -72,6 +78,9 @@ export class TaskListPresenter extends BasePresenter {
7278
},
7379
});
7480

81+
const devEnvironments = onlyDevEnvironments(project.environments);
82+
const nonDevEnvironments = exceptDevEnvironments(project.environments);
83+
7584
const tasks = await this._replica.$queryRaw<
7685
{
7786
id: string;
@@ -83,12 +92,21 @@ export class TaskListPresenter extends BasePresenter {
8392
triggerSource: TaskTriggerSource;
8493
}[]
8594
>`
86-
WITH workers AS (
95+
WITH non_dev_workers AS (
96+
SELECT wd."workerId" AS id
97+
FROM ${sqlDatabaseSchema}."WorkerDeploymentPromotion" wdp
98+
INNER JOIN ${sqlDatabaseSchema}."WorkerDeployment" wd
99+
ON wd.id = wdp."deploymentId"
100+
WHERE wdp."environmentId" IN (${Prisma.join(nonDevEnvironments.map((e) => e.id))})
101+
AND wdp."label" = ${CURRENT_DEPLOYMENT_LABEL}
102+
),
103+
workers AS (
87104
SELECT DISTINCT ON ("runtimeEnvironmentId") id, "runtimeEnvironmentId", version
88105
FROM ${sqlDatabaseSchema}."BackgroundWorker"
89106
WHERE "runtimeEnvironmentId" IN (${Prisma.join(
90-
filterOrphanedEnvironments(project.environments).map((e) => e.id)
107+
filterOrphanedEnvironments(devEnvironments).map((e) => e.id)
91108
)})
109+
OR id IN (SELECT id FROM non_dev_workers)
92110
ORDER BY "runtimeEnvironmentId", "createdAt" DESC
93111
)
94112
SELECT tasks.id, slug, "filePath", "exportName", "triggerSource", tasks."runtimeEnvironmentId", tasks."createdAt"

apps/webapp/app/presenters/v3/TestPresenter.server.ts

Lines changed: 38 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { sqlDatabaseSchema, PrismaClient, prisma } from "~/db.server";
33
import { TestSearchParams } from "~/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.test/route";
44
import { sortEnvironments } from "~/utils/environmentSort";
55
import { createSearchParams } from "~/utils/searchParams";
6-
import { getUsername } from "~/utils/username";
6+
import { findCurrentWorkerDeployment } from "~/v3/models/workerDeployment.server";
7+
import { BasePresenter } from "./basePresenter.server";
78

89
type TaskListOptions = {
910
userId: string;
@@ -15,16 +16,10 @@ export type TaskList = Awaited<ReturnType<TestPresenter["call"]>>;
1516
export type TaskListItem = NonNullable<TaskList["tasks"]>[0];
1617
export type SelectedEnvironment = NonNullable<TaskList["selectedEnvironment"]>;
1718

18-
export class TestPresenter {
19-
#prismaClient: PrismaClient;
20-
21-
constructor(prismaClient: PrismaClient = prisma) {
22-
this.#prismaClient = prismaClient;
23-
}
24-
19+
export class TestPresenter extends BasePresenter {
2520
public async call({ userId, projectSlug, url }: TaskListOptions) {
2621
// Find the project scoped to the organization
27-
const project = await this.#prismaClient.project.findFirstOrThrow({
22+
const project = await this._replica.project.findFirstOrThrow({
2823
select: {
2924
id: true,
3025
environments: {
@@ -85,31 +80,8 @@ export class TestPresenter {
8580
};
8681
}
8782

88-
//get all possible tasks
89-
const tasks = await this.#prismaClient.$queryRaw<
90-
{
91-
id: string;
92-
version: string;
93-
taskIdentifier: string;
94-
filePath: string;
95-
exportName: string;
96-
friendlyId: string;
97-
triggerSource: TaskTriggerSource;
98-
}[]
99-
>`WITH workers AS (
100-
SELECT
101-
bw.*,
102-
ROW_NUMBER() OVER(ORDER BY string_to_array(bw.version, '.')::int[] DESC) AS rn
103-
FROM
104-
${sqlDatabaseSchema}."BackgroundWorker" bw
105-
WHERE "runtimeEnvironmentId" = ${matchingEnvironment.id}
106-
),
107-
latest_workers AS (SELECT * FROM workers WHERE rn = 1)
108-
SELECT bwt.id, version, slug as "taskIdentifier", "filePath", "exportName", bwt."friendlyId", bwt."triggerSource"
109-
FROM latest_workers
110-
JOIN ${sqlDatabaseSchema}."BackgroundWorkerTask" bwt ON bwt."workerId" = latest_workers.id
111-
ORDER BY bwt."exportName" ASC;
112-
`;
83+
const isDev = matchingEnvironment.type === "DEVELOPMENT";
84+
const tasks = await this.#getTasks(matchingEnvironment.id, isDev);
11385

11486
return {
11587
hasSelectedEnvironment: true as const,
@@ -118,8 +90,7 @@ export class TestPresenter {
11890
tasks: tasks.map((task) => {
11991
return {
12092
id: task.id,
121-
version: task.version,
122-
taskIdentifier: task.taskIdentifier,
93+
taskIdentifier: task.slug,
12394
filePath: task.filePath,
12495
exportName: task.exportName,
12596
friendlyId: task.friendlyId,
@@ -128,4 +99,35 @@ export class TestPresenter {
12899
}),
129100
};
130101
}
102+
103+
async #getTasks(envId: string, isDev: boolean) {
104+
if (isDev) {
105+
return await this._replica.$queryRaw<
106+
{
107+
id: string;
108+
version: string;
109+
slug: string;
110+
filePath: string;
111+
exportName: string;
112+
friendlyId: string;
113+
triggerSource: TaskTriggerSource;
114+
}[]
115+
>`WITH workers AS (
116+
SELECT
117+
bw.*,
118+
ROW_NUMBER() OVER(ORDER BY string_to_array(bw.version, '.')::int[] DESC) AS rn
119+
FROM
120+
${sqlDatabaseSchema}."BackgroundWorker" bw
121+
WHERE "runtimeEnvironmentId" = ${envId}
122+
),
123+
latest_workers AS (SELECT * FROM workers WHERE rn = 1)
124+
SELECT bwt.id, version, slug, "filePath", "exportName", bwt."friendlyId", bwt."triggerSource"
125+
FROM latest_workers
126+
JOIN ${sqlDatabaseSchema}."BackgroundWorkerTask" bwt ON bwt."workerId" = latest_workers.id
127+
ORDER BY bwt."exportName" ASC;`;
128+
} else {
129+
const currentDeployment = await findCurrentWorkerDeployment(envId);
130+
return currentDeployment?.worker?.tasks ?? [];
131+
}
132+
}
131133
}

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.deployments/route.tsx

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1-
import { CommandLineIcon, ServerIcon } from "@heroicons/react/20/solid";
2-
import { Outlet, useParams } from "@remix-run/react";
1+
import { ArrowPathIcon, CommandLineIcon, ServerIcon } from "@heroicons/react/20/solid";
2+
import { Outlet, useLocation, useParams } from "@remix-run/react";
33
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
4-
import { TerminalIcon, TerminalSquareIcon } from "lucide-react";
54
import { typedjson, useTypedLoaderData } from "remix-typedjson";
65
import { z } from "zod";
76
import { BlankstateInstructions } from "~/components/BlankstateInstructions";
87
import { UserAvatar } from "~/components/UserProfilePhoto";
98
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
109
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
1110
import { Badge } from "~/components/primitives/Badge";
12-
import { LinkButton } from "~/components/primitives/Buttons";
11+
import { Button, LinkButton } from "~/components/primitives/Buttons";
1312
import { DateTime } from "~/components/primitives/DateTime";
13+
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
1414
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
1515
import { PaginationControls } from "~/components/primitives/Pagination";
1616
import { Paragraph } from "~/components/primitives/Paragraph";
@@ -24,24 +24,26 @@ import {
2424
TableBlankRow,
2525
TableBody,
2626
TableCell,
27-
TableCellChevron,
27+
TableCellMenu,
2828
TableHeader,
2929
TableHeaderCell,
3030
TableRow,
3131
} from "~/components/primitives/Table";
3232
import { TextLink } from "~/components/primitives/TextLink";
3333
import { DeploymentStatus } from "~/components/runs/v3/DeploymentStatus";
34+
import { RollbackDeploymentDialog } from "~/components/runs/v3/RollbackDeploymentDialog";
3435
import { useOrganization } from "~/hooks/useOrganizations";
3536
import { useProject } from "~/hooks/useProject";
3637
import { useUser } from "~/hooks/useUser";
37-
import { DeploymentListPresenter } from "~/presenters/v3/DeploymentListPresenter.server";
38+
import {
39+
DeploymentListItem,
40+
DeploymentListPresenter,
41+
} from "~/presenters/v3/DeploymentListPresenter.server";
3842
import { requireUserId } from "~/services/session.server";
39-
import { cn } from "~/utils/cn";
4043
import {
4144
ProjectParamSchema,
4245
docsPath,
4346
v3DeploymentPath,
44-
v3DeploymentsPath,
4547
v3EnvironmentVariablesPath,
4648
} from "~/utils/pathBuilder";
4749
import { createSearchParams } from "~/utils/searchParams";
@@ -166,7 +168,7 @@ export default function Page() {
166168
"–"
167169
)}
168170
</TableCell>
169-
<TableCellChevron to={path} />
171+
<DeploymentActionsCell deployment={deployment} path={path} />
170172
</TableRow>
171173
);
172174
})
@@ -240,3 +242,35 @@ function CreateDeploymentInstructions() {
240242
</MainCenteredContainer>
241243
);
242244
}
245+
246+
function DeploymentActionsCell({
247+
deployment,
248+
path,
249+
}: {
250+
deployment: DeploymentListItem;
251+
path: string;
252+
}) {
253+
const location = useLocation();
254+
const project = useProject();
255+
256+
if (deployment.isCurrent || !deployment.isDeployed) return <TableCell to={path}>{""}</TableCell>;
257+
258+
return (
259+
<TableCellMenu isSticky>
260+
{!deployment.isCurrent && deployment.isDeployed && (
261+
<Dialog>
262+
<DialogTrigger asChild>
263+
<Button variant="small-menu-item" LeadingIcon={ArrowPathIcon}>
264+
Rollback
265+
</Button>
266+
</DialogTrigger>
267+
<RollbackDeploymentDialog
268+
projectId={project.id}
269+
deploymentShortCode={deployment.shortCode}
270+
redirectPath={`${location.pathname}${location.search}`}
271+
/>
272+
</Dialog>
273+
)}
274+
</TableCellMenu>
275+
);
276+
}

0 commit comments

Comments
 (0)