Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"use client";

import { type Deployment, collection, collectionManager } from "@/lib/collections";
import { shortenId } from "@/lib/shorten-id";
import { trpc } from "@/lib/trpc/client";
import { eq, inArray, useLiveQuery } from "@tanstack/react-db";
import { CircleInfo, CodeBranch, CodeCommit, Link4 } from "@unkey/icons";
import { Badge, Button, DialogContainer, toast } from "@unkey/ui";
import { StatusIndicator } from "../../details/active-deployment-card/status-indicator";

type DeploymentSectionProps = {
title: string;
deployment: Deployment;
isLive: boolean;
showSignal?: boolean;
};

const DeploymentSection = ({ title, deployment, isLive, showSignal }: DeploymentSectionProps) => (
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="text-[13px] text-grayA-11">{title}</h3>
<CircleInfo size="sm-regular" className="text-gray-9" />
</div>
<DeploymentCard deployment={deployment} isLive={isLive} showSignal={showSignal} />
</div>
);

type PromotionDialogProps = {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
targetDeployment: Deployment;
liveDeployment: Deployment;
};

export const PromotionDialog = ({
isOpen,
onOpenChange,
targetDeployment,
liveDeployment,
}: PromotionDialogProps) => {
const utils = trpc.useUtils();
const domainCollection = collectionManager.getProjectCollections(
liveDeployment.projectId,
).domains;
const domains = useLiveQuery((q) =>
q
.from({ domain: domainCollection })
.where(({ domain }) => inArray(domain.sticky, ["environment", "live"]))
.where(({ domain }) => eq(domain.deploymentId, liveDeployment.id)),
);
const promote = trpc.deploy.deployment.promote.useMutation({
onSuccess: () => {
utils.invalidate();
toast.success("Promotion completed", {
description: `Successfully promoted to deployment ${targetDeployment.id}`,
});
// hack to revalidate
try {
// @ts-expect-error Their docs say it's here
collection.projects.utils.refetch();
// @ts-expect-error Their docs say it's here
collection.deployments.utils.refetch();
// @ts-expect-error Their docs say it's here
collection.domains.utils.refetch();
} catch (error) {
console.error("Refetch error:", error);
}

onOpenChange(false);
},
onError: (error) => {
toast.error("Promotion failed", {
description: error.message,
});
},
});

const handlePromotion = async () => {
await promote
.mutateAsync({
targetDeploymentId: targetDeployment.id,
})
.catch((error) => {
console.error("Promotion error:", error);
});
};
Comment on lines +78 to +86
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Remove redundant error handling in handlePromotion.

The error is already handled by the mutation's onError callback, making the additional .catch() redundant. The current implementation could also prevent errors from being properly surfaced to the UI.

Apply this diff to simplify the error handling:

 const handlePromotion = async () => {
-  await promote
-    .mutateAsync({
-      targetDeploymentId: targetDeployment.id,
-    })
-    .catch((error) => {
-      console.error("Promotion error:", error);
-    });
+  await promote.mutateAsync({
+    targetDeploymentId: targetDeployment.id,
+  });
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handlePromotion = async () => {
await promote
.mutateAsync({
targetDeploymentId: targetDeployment.id,
})
.catch((error) => {
console.error("Promotion error:", error);
});
};
const handlePromotion = async () => {
await promote.mutateAsync({
targetDeploymentId: targetDeployment.id,
});
};
🤖 Prompt for AI Agents
In
apps/dashboard/app/(app)/projects/[projectId]/deployments/components/promotion-dialog.tsx
around lines 78 to 86, the handlePromotion function attaches a redundant
.catch() to promote.mutateAsync even though the mutation already handles errors
via its onError callback; remove the .catch(...) chain and simply await
promote.mutateAsync({ targetDeploymentId: targetDeployment.id }) so errors are
propagated to the mutation's handlers and not swallowed or double-handled by
this function.


return (
<DialogContainer
isOpen={isOpen}
onOpenChange={onOpenChange}
title="Promotion to version"
subTitle="Switch the active deployment to a target stable version"
footer={
<Button
variant="primary"
size="xlg"
onClick={handlePromotion}
disabled={promote.isLoading}
loading={promote.isLoading}
className="w-full rounded-lg"
>
Promote to
{targetDeployment.gitCommitSha
? shortenId(targetDeployment.gitCommitSha)
: targetDeployment.id}
</Button>
}
>
<div className="space-y-9">
<DeploymentSection
title="Live Deployment"
deployment={liveDeployment}
isLive={true}
showSignal={true}
/>
<div>
{domains.data.map((domain) => (
<div
key={domain.id}
className="border border-gray-4 border-t-0 first:border-t first:rounded-t-[14px] last:rounded-b-[14px] last:border-b w-full px-4 py-3 flex justify-between items-center"
>
<div className="flex items-center">
<Link4 className="text-gray-9" size="sm-medium" />
<div className="text-gray-12 font-medium text-xs ml-3 mr-2">{domain.domain}</div>
<div className="ml-3" />
</div>
</div>
))}
</div>
<DeploymentSection title="Target Deployment" deployment={targetDeployment} isLive={false} />
</div>
</DialogContainer>
);
};

type DeploymentCardProps = {
deployment: Deployment;
isLive: boolean;
showSignal?: boolean;
};

const DeploymentCard = ({ deployment, isLive, showSignal }: DeploymentCardProps) => (
<div className="bg-white dark:bg-black border border-grayA-5 rounded-lg p-4 relative">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<StatusIndicator withSignal={showSignal} />
<div>
<div className="flex items-center gap-2">
<span className="text-xs text-accent-12 font-mono">
{`${deployment.id.slice(0, 3)}...${deployment.id.slice(-4)}`}
</span>
<Badge
variant={isLive ? "success" : "primary"}
className={`px-1.5 capitalize ${isLive ? "text-successA-11" : "text-grayA-11"}`}
>
{isLive ? "Live" : deployment.status}
</Badge>
</div>
<div className="text-xs text-grayA-9">
{deployment.gitCommitMessage || `${isLive ? "Current active" : "Target"} deployment`}
</div>
</div>
</div>
<div className="flex gap-1.5">
<div className="flex items-center gap-1.5 px-2 py-1 bg-gray-3 rounded-md text-xs text-grayA-11 max-w-[100px]">
<CodeBranch size="sm-regular" className="shrink-0 text-gray-12" />
<span className="truncate">{deployment.gitBranch}</span>
</div>
<div className="flex items-center gap-1.5 px-2 py-1 bg-gray-3 rounded-md text-xs text-grayA-11">
<CodeCommit size="sm-regular" className="shrink-0 text-gray-12" />
<span>{shortenId(deployment.gitCommitSha ?? "")}</span>
</div>
</div>
</div>
</div>
);
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { type Deployment, collection, collectionManager } from "@/lib/collections";
import { shortenId } from "@/lib/shorten-id";
import { trpc } from "@/lib/trpc/client";
import { eq, inArray, useLiveQuery } from "@tanstack/react-db";
import { inArray, useLiveQuery } from "@tanstack/react-db";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Scope rollback domains to environment_id (and re-add eq).

Without filtering by environment_id, the dialog may show domains from other environments. Align this with FindDomainsForRollback (env + sticky filter) to avoid cross-environment leakage.

Apply:

-import { inArray, useLiveQuery } from "@tanstack/react-db";
+import { and, eq, inArray, useLiveQuery } from "@tanstack/react-db";
@@
-  const domains = useLiveQuery((q) =>
-    q
-      .from({ domain: domainCollection })
-      .where(({ domain }) => inArray(domain.sticky, ["environment", "live"])),
-  );
+  const domains = useLiveQuery((q) =>
+    q
+      .from({ domain: domainCollection })
+      .where(({ domain }) =>
+        and(
+          eq(domain.environmentId, liveDeployment.environmentId),
+          inArray(domain.sticky, ["environment", "live"]),
+        ),
+      )
+      .orderBy(({ domain }) => domain.domain, "asc"),
+  );

Also applies to: 45-49

🤖 Prompt for AI Agents
In
apps/dashboard/app/(app)/projects/[projectId]/deployments/components/rollback-dialog.tsx
around lines 6 and 45-49, the rollback domain selection currently omits an
environment_id filter and removed the eq import; update the top import to
include eq (e.g., import { inArray, useLiveQuery, eq } from
"@tanstack/react-db") and modify the domain query to add an
eq(domain.environment_id, currentEnvironmentId) predicate (alongside the
existing sticky/inArray filters) so results are scoped to the current
environment, matching FindDomainsForRollback’s env + sticky behavior.

import { CircleInfo, CodeBranch, CodeCommit, Link4 } from "@unkey/icons";
import { Badge, Button, DialogContainer, toast } from "@unkey/ui";
import { StatusIndicator } from "../../details/active-deployment-card/status-indicator";
Expand Down Expand Up @@ -45,8 +45,7 @@ export const RollbackDialog = ({
const domains = useLiveQuery((q) =>
q
.from({ domain: domainCollection })
.where(({ domain }) => inArray(domain.sticky, ["environment", "live"]))
.where(({ domain }) => eq(domain.deploymentId, liveDeployment.id)),
.where(({ domain }) => inArray(domain.sticky, ["environment", "live"])),
);
const rollback = trpc.deploy.deployment.rollback.useMutation({
onSuccess: () => {
Expand All @@ -58,6 +57,10 @@ export const RollbackDialog = ({
try {
// @ts-expect-error Their docs say it's here
collection.projects.utils.refetch();
// @ts-expect-error Their docs say it's here
collection.deployments.utils.refetch();
// @ts-expect-error Their docs say it's here
collection.domains.utils.refetch();
} catch (error) {
console.error("Refetch error:", error);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"use client";
import { type MenuItem, TableActionPopover } from "@/components/logs/table-action.popover";
import { TableActionPopover } from "@/components/logs/table-action.popover";
import type { Deployment, Environment } from "@/lib/collections";
import { ArrowDottedRotateAnticlockwise } from "@unkey/icons";
import { ArrowDottedRotateAnticlockwise, ChevronUp } from "@unkey/icons";
import { useState } from "react";
import { PromotionDialog } from "../../../promotion-dialog";
import { RollbackDialog } from "../../../rollback-dialog";

type DeploymentListTableActionsProps = {
Expand All @@ -17,16 +18,51 @@ export const DeploymentListTableActions = ({
environment,
}: DeploymentListTableActionsProps) => {
const [isRollbackModalOpen, setIsRollbackModalOpen] = useState(false);
const menuItems = getDeploymentListTableActionItems(
selectedDeployment,
liveDeployment,
environment,
setIsRollbackModalOpen,
);
const [isPromotionModalOpen, setIsPromotionModalOpen] = useState(false);

const canRollback =
liveDeployment &&
environment?.slug === "production" &&
selectedDeployment.status === "ready" &&
selectedDeployment.id !== liveDeployment.id;

// TODO
// This logic is slightly flawed as it does not allow you to promote a deployment that
// is currently live due to a rollback.
const canPromote =
liveDeployment &&
environment?.slug === "production" &&
selectedDeployment.status === "ready" &&
selectedDeployment.id !== liveDeployment.id;

return (
<>
<TableActionPopover items={menuItems} />
<TableActionPopover
items={[
{
id: "rollback",
label: "Rollback",
icon: <ArrowDottedRotateAnticlockwise size="md-regular" />,
disabled: !canRollback,
onClick: () => {
if (canRollback) {
setIsRollbackModalOpen(true);
}
},
},
{
id: "Promote",
label: "Promote",
icon: <ChevronUp size="md-regular" />,
disabled: !canPromote,
onClick: () => {
if (canPromote) {
setIsPromotionModalOpen(true);
}
},
},
]}
/>
{liveDeployment && selectedDeployment && (
<RollbackDialog
isOpen={isRollbackModalOpen}
Expand All @@ -35,34 +71,14 @@ export const DeploymentListTableActions = ({
targetDeployment={selectedDeployment}
/>
)}
{liveDeployment && selectedDeployment && (
<PromotionDialog
isOpen={isPromotionModalOpen}
onOpenChange={setIsPromotionModalOpen}
liveDeployment={liveDeployment}
targetDeployment={selectedDeployment}
/>
)}
</>
);
};

const getDeploymentListTableActionItems = (
selectedDeployment: Deployment,
liveDeployment: Deployment | undefined,
environment: Environment | undefined,
setIsRollbackModalOpen: (open: boolean) => void,
): MenuItem[] => {
// Rollback is only enabled for production deployments that are ready and not currently active
const canRollback =
liveDeployment &&
environment?.slug === "production" &&
selectedDeployment.status === "ready" &&
selectedDeployment.id !== liveDeployment.id;

return [
{
id: "rollback",
label: "Rollback",
icon: <ArrowDottedRotateAnticlockwise size="md-regular" />,
disabled: !canRollback,
onClick: () => {
if (canRollback) {
setIsRollbackModalOpen(true);
}
},
},
];
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { eq, useLiveQuery } from "@tanstack/react-db";
import { useProjectLayout } from "../../../../layout-provider";

type Props = {
deploymentId: string;
// I couldn't figure out how to make the domains revalidate on a rollback
// From my understanding it should already work, because we're using the
// .util.refetch() in the trpc mutation, but it doesn't.
// We need to investigate this later
hackyRevalidateDependency?: unknown;
};

export const DomainList = ({ deploymentId, hackyRevalidateDependency }: Props) => {
const { collections } = useProjectLayout();
const domains = useLiveQuery(
(q) =>
q
.from({ domain: collections.domains })
.where(({ domain }) => eq(domain.deploymentId, deploymentId))
.orderBy(({ domain }) => domain.domain, "asc"),
[hackyRevalidateDependency],
);
Comment on lines +21 to +22
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Include deploymentId in the useLiveQuery dependency array.

If the component is reused with a different deploymentId but the revalidate key stays unchanged, the query won’t rerun.

-    [hackyRevalidateDependency],
+    [deploymentId, hackyRevalidateDependency],
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
[hackyRevalidateDependency],
);
[deploymentId, hackyRevalidateDependency],
);
🤖 Prompt for AI Agents
In
apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/components/domain_list.tsx
around lines 21 to 22, the useLiveQuery dependency array only contains
hackyRevalidateDependency and must also include deploymentId so the query reruns
when the component is reused with a different deployment; update the dependency
array to include deploymentId (and keep existing dependencies) so the hook will
re-evaluate whenever deploymentId changes.


return (
<ul className="flex flex-col list-none py-2">
{domains.data.map((domain) => (
<li key={domain.id}>https://{domain.domain}</li>
))}
</ul>
);
};
Loading