Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions apps/webapp/app/components/primitives/Tabs.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { Link, NavLink, useLocation } from "@remix-run/react";
import { NavLink } from "@remix-run/react";
import { motion } from "framer-motion";
import { ReactNode, useRef } from "react";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { projectPubSub } from "~/v3/services/projectPubSub.server";
import { ShortcutKey } from "./ShortcutKey";

export type TabsProps = {
Expand Down
46 changes: 44 additions & 2 deletions apps/webapp/app/components/runs/v3/RollbackDeploymentDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function RollbackDeploymentDialog({

return (
<DialogContent key="rollback">
<DialogHeader>Roll back to this deployment?</DialogHeader>
<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
Expand All @@ -50,7 +50,49 @@ export function RollbackDeploymentDialog({
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Rolling back..." : "Roll back deployment"}
{isLoading ? "Rolling back..." : "Rollback deployment"}
</Button>
</Form>
</DialogFooter>
</DialogContent>
);
}

export 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 ? "spinner-white" : ArrowPathIcon}
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Promoting..." : "Promote deployment"}
</Button>
</Form>
</DialogFooter>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
ArrowPathIcon,
ArrowUturnLeftIcon,
ArrowUturnRightIcon,
BookOpenIcon,
ServerStackIcon,
} from "@heroicons/react/20/solid";
Expand Down Expand Up @@ -41,7 +42,10 @@ import {
deploymentStatuses,
} from "~/components/runs/v3/DeploymentStatus";
import { RetryDeploymentIndexingDialog } from "~/components/runs/v3/RetryDeploymentIndexingDialog";
import { RollbackDeploymentDialog } from "~/components/runs/v3/RollbackDeploymentDialog";
import {
PromoteDeploymentDialog,
RollbackDeploymentDialog,
} from "~/components/runs/v3/RollbackDeploymentDialog";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useUser } from "~/hooks/useUser";
Expand All @@ -58,6 +62,7 @@ import {
} from "~/utils/pathBuilder";
import { createSearchParams } from "~/utils/searchParams";
import { deploymentIndexingIsRetryable } from "~/v3/deploymentStatus";
import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions";

export const meta: MetaFunction = () => {
return [
Expand Down Expand Up @@ -106,6 +111,8 @@ export default function Page() {

const { deploymentParam } = useParams();

const currentDeployment = deployments.find((d) => d.isCurrent);

return (
<PageContainer>
<NavBar>
Expand Down Expand Up @@ -234,6 +241,7 @@ export default function Page() {
deployment={deployment}
path={path}
isSelected={isSelected}
currentDeployment={currentDeployment}
/>
</TableRow>
);
Expand Down Expand Up @@ -320,18 +328,25 @@ function DeploymentActionsCell({
deployment,
path,
isSelected,
currentDeployment,
}: {
deployment: DeploymentListItem;
path: string;
isSelected: boolean;
currentDeployment?: DeploymentListItem;
}) {
const location = useLocation();
const project = useProject();

const canRollback = !deployment.isCurrent && deployment.isDeployed;
const canBeMadeCurrent = !deployment.isCurrent && deployment.isDeployed;
const canRetryIndexing = deployment.isLatest && deploymentIndexingIsRetryable(deployment);
const canBeRolledBack =
canBeMadeCurrent &&
currentDeployment?.version &&
compareDeploymentVersions(deployment.version, currentDeployment.version) === -1;
const canBePromoted = canBeMadeCurrent && !canBeRolledBack;

if (!canRollback && !canRetryIndexing) {
if (!canBeMadeCurrent && !canRetryIndexing) {
return (
<TableCell to={path} isSelected={isSelected}>
{""}
Expand All @@ -345,7 +360,7 @@ function DeploymentActionsCell({
isSelected={isSelected}
popoverContent={
<>
{canRollback && (
{canBeRolledBack && (
<Dialog>
<DialogTrigger asChild>
<Button
Expand All @@ -365,6 +380,26 @@ function DeploymentActionsCell({
/>
</Dialog>
)}
{canBePromoted && (
<Dialog>
<DialogTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={ArrowUturnRightIcon}
leadingIconClassName="text-blue-500"
fullWidth
textAlignLeft
>
Promote…
</Button>
</DialogTrigger>
<PromoteDeploymentDialog
projectId={project.id}
deploymentShortCode={deployment.shortCode}
redirectPath={`${location.pathname}${location.search}`}
/>
</Dialog>
)}
{canRetryIndexing && (
<Dialog>
<DialogTrigger asChild>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { ChangeCurrentDeploymentService } from "~/v3/services/changeCurrentDeployment.server";

const ParamsSchema = z.object({
deploymentVersion: z.string(),
});

export async function action({ request, params }: ActionFunctionArgs) {
// Ensure this is a POST request
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
}

const parsedParams = ParamsSchema.safeParse(params);

if (!parsedParams.success) {
return json({ error: "Invalid params" }, { status: 400 });
}

// Next authenticate the request
const authenticationResult = await authenticateApiRequest(request);

if (!authenticationResult) {
logger.info("Invalid or missing api key", { url: request.url });
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}

const authenticatedEnv = authenticationResult.environment;

const { deploymentVersion } = parsedParams.data;

const deployment = await prisma.workerDeployment.findFirst({
where: {
version: deploymentVersion,
environmentId: authenticatedEnv.id,
},
});

if (!deployment) {
return json({ error: "Deployment not found" }, { status: 404 });
}

try {
const service = new ChangeCurrentDeploymentService();
await service.call(deployment, "promote");

return json(
{
id: deployment.friendlyId,
version: deployment.version,
shortCode: deployment.shortCode,
},
{ status: 200 }
);
} catch (error) {
if (error instanceof ServiceValidationError) {
return json({ error: error.message }, { status: 400 });
} else {
return json({ error: "Failed to promote deployment" }, { status: 500 });
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { parse } from "@conform-to/zod";
import { ActionFunction, json } from "@remix-run/node";
import { z } from "zod";
import { prisma } from "~/db.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { ChangeCurrentDeploymentService } from "~/v3/services/changeCurrentDeployment.server";

export const rollbackSchema = z.object({
redirectUrl: z.string(),
});

const ParamSchema = z.object({
projectId: z.string(),
deploymentShortCode: z.string(),
});

export const action: ActionFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
const { projectId, deploymentShortCode } = ParamSchema.parse(params);

const formData = await request.formData();
const submission = parse(formData, { schema: rollbackSchema });

if (!submission.value) {
return json(submission);
}

try {
const project = await prisma.project.findUnique({
where: {
id: projectId,
organization: {
members: {
some: {
userId,
},
},
},
},
});

if (!project) {
return redirectWithErrorMessage(submission.value.redirectUrl, request, "Project not found");
}

const deployment = await prisma.workerDeployment.findUnique({
where: {
projectId_shortCode: {
projectId: project.id,
shortCode: deploymentShortCode,
},
},
});

if (!deployment) {
return redirectWithErrorMessage(
submission.value.redirectUrl,
request,
"Deployment not found"
);
}

const rollbackService = new ChangeCurrentDeploymentService();
await rollbackService.call(deployment, "promote");

return redirectWithSuccessMessage(
submission.value.redirectUrl,
request,
`Promoted deployment version ${deployment.version} to current.`
);
} catch (error) {
if (error instanceof Error) {
logger.error("Failed to promote deployment", {
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
});
submission.error = { runParam: error.message };
return json(submission);
} else {
logger.error("Failed to promote deployment", { error });
submission.error = { runParam: JSON.stringify(error) };
return json(submission);
}
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { prisma } from "~/db.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { RollbackDeploymentService } from "~/v3/services/rollbackDeployment.server";
import { ChangeCurrentDeploymentService } from "~/v3/services/changeCurrentDeployment.server";

export const rollbackSchema = z.object({
redirectUrl: z.string(),
Expand Down Expand Up @@ -65,8 +65,8 @@ export const action: ActionFunction = async ({ request, params }) => {
);
}

const rollbackService = new RollbackDeploymentService();
await rollbackService.call(deployment);
const rollbackService = new ChangeCurrentDeploymentService();
await rollbackService.call(deployment, "rollback");

return redirectWithSuccessMessage(
submission.value.redirectUrl,
Expand Down
4 changes: 3 additions & 1 deletion apps/webapp/app/v3/authenticatedSocketConnection.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ export class AuthenticatedSocketConnection {
});
});
},
canSendMessage: () => ws.readyState === WebSocket.OPEN,
canSendMessage() {
return ws.readyState === WebSocket.OPEN;
},
});

this._consumer = new DevQueueConsumer(this.id, authenticatedEnv, this._sender, {
Expand Down
Loading