-
Notifications
You must be signed in to change notification settings - Fork 610
feat: promote a deployment #4015
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
03963a2
5ac10bc
c64d87c
8111e14
e63bbc5
ad45355
1bde1a2
cf86218
53eafb4
74b76c1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| }); | ||
| }; | ||
|
|
||
| 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 |
|---|---|---|
|
|
@@ -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"; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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"; | ||
|
|
@@ -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: () => { | ||
|
|
@@ -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); | ||
| } | ||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||
|
|
||||||||||
| return ( | ||||||||||
| <ul className="flex flex-col list-none py-2"> | ||||||||||
| {domains.data.map((domain) => ( | ||||||||||
| <li key={domain.id}>https://{domain.domain}</li> | ||||||||||
| ))} | ||||||||||
| </ul> | ||||||||||
| ); | ||||||||||
| }; | ||||||||||
There was a problem hiding this comment.
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
onErrorcallback, 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
🤖 Prompt for AI Agents