From a1187251e91f3afb1f6b47ba6f606ddab489d6f5 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Thu, 3 Jul 2025 12:52:27 -0700 Subject: [PATCH 1/2] feat: redeploy based on job status --- .../DeploymentVersionJobsTable.tsx | 40 ++- .../_components/RedeployReleaseTargets.tsx | 253 ++++++++++++++++++ .../deployment-version/ForceDeployVersion.tsx | 2 +- .../RedeployVersionDialog.tsx | 2 +- .../dropdown/RedeployJobsDialog.tsx | 2 +- packages/api/src/root.ts | 6 +- packages/api/src/router/redeploy.ts | 38 ++- 7 files changed, 324 insertions(+), 19 deletions(-) create mode 100644 apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/jobs/_components/RedeployReleaseTargets.tsx diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/jobs/_components/DeploymentVersionJobsTable.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/jobs/_components/DeploymentVersionJobsTable.tsx index 6abbffb99..951887ddf 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/jobs/_components/DeploymentVersionJobsTable.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/jobs/_components/DeploymentVersionJobsTable.tsx @@ -26,12 +26,14 @@ import { Table, TableBody, TableCell } from "@ctrlplane/ui/table"; import { failedStatuses, JobStatus } from "@ctrlplane/validators/jobs"; import { OverrideJobStatusDialog } from "~/app/[workspaceSlug]/(app)/_components/job/OverrideJobStatusDialog"; -import { ForceDeployVersionDialog } from "~/app/[workspaceSlug]/(app)/(deploy)/_components/deployment-version/ForceDeployVersion"; -import { RedeployVersionDialog } from "~/app/[workspaceSlug]/(app)/(deploy)/_components/deployment-version/RedeployVersionDialog"; import { Sidebars } from "~/app/[workspaceSlug]/sidebars"; import { api } from "~/trpc/react"; import { CollapsibleRow } from "./CollapsibleRow"; import { EnvironmentTableRow } from "./EnvironmentTableRow"; +import { + ForceDeployReleaseTargetsDialog, + RedeployReleaseTargetsDialog, +} from "./RedeployReleaseTargets"; import { ReleaseTargetRow } from "./ReleaseTargetRow"; type DeploymentVersionJobsTableProps = { @@ -67,24 +69,29 @@ type JobActionsDropdownMenuProps = { jobs: { id: string; status: SCHEMA.Job["status"] }[]; deployment: { id: string; name: string }; environment: { id: string; name: string }; - resource?: { id: string; name: string }; + releaseTargets: { + id: string; + resource: { id: string; name: string }; + latestJob: { id: string; status: JobStatus }; + }[]; }; const JobActionsDropdownMenu: React.FC = ( props, ) => { + const [open, setOpen] = useState(false); const { jobs } = props; const utils = api.useUtils(); return ( - + - - + e.stopPropagation()}> + setOpen(false)}> e.preventDefault()} className="flex items-center gap-2" @@ -92,8 +99,11 @@ const JobActionsDropdownMenu: React.FC = ( Redeploy - - + + setOpen(false)} + > e.preventDefault()} className="flex items-center gap-2" @@ -101,7 +111,7 @@ const JobActionsDropdownMenu: React.FC = ( Force deploy - + utils.deployment.version.job.list.invalidate()} @@ -194,6 +204,18 @@ export const DeploymentVersionJobsTable: React.FC< .filter(isPresent)} deployment={deployment} environment={environment} + releaseTargets={releaseTargets + .filter(({ jobs }) => jobs.length > 0) + .map((rt) => { + const latestJob = rt.jobs.at(0)!; + return { + ...rt, + latestJob: { + id: latestJob.id, + status: latestJob.status as JobStatus, + }, + }; + })} /> } diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/jobs/_components/RedeployReleaseTargets.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/jobs/_components/RedeployReleaseTargets.tsx new file mode 100644 index 000000000..efe1bb404 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/jobs/_components/RedeployReleaseTargets.tsx @@ -0,0 +1,253 @@ +import type React from "react"; +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { capitalCase } from "change-case"; + +import { Badge } from "@ctrlplane/ui/badge"; +import { Button } from "@ctrlplane/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@ctrlplane/ui/dialog"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@ctrlplane/ui/hover-card"; +import { Label } from "@ctrlplane/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@ctrlplane/ui/select"; +import { toast } from "@ctrlplane/ui/toast"; +import { JobStatus } from "@ctrlplane/validators/jobs"; + +import { api } from "~/trpc/react"; + +type Job = { id: string; status: JobStatus }; + +type ReleaseTarget = { + id: string; + resource: { id: string; name: string }; + latestJob: Job; +}; + +const ALL_JOBS_STATUS = "all"; + +const useFilterByJobStatus = (releaseTargets: ReleaseTarget[]) => { + const [selectedStatus, setSelectedStatus] = useState< + JobStatus | typeof ALL_JOBS_STATUS + >(ALL_JOBS_STATUS); + const [filteredReleaseTargets, setFilteredReleaseTargets] = + useState(releaseTargets); + + const onSelectStatus = (status: JobStatus | typeof ALL_JOBS_STATUS) => { + setSelectedStatus(status); + if (status === ALL_JOBS_STATUS) { + setFilteredReleaseTargets(releaseTargets); + return; + } + + const filteredReleaseTargets = releaseTargets.filter( + ({ latestJob }) => latestJob.status === status, + ); + setFilteredReleaseTargets(filteredReleaseTargets); + }; + + return { + selectedStatus, + filteredReleaseTargets, + onSelectStatus, + }; +}; + +const JobStatusSelector: React.FC<{ + value: JobStatus | typeof ALL_JOBS_STATUS; + onChange: (value: JobStatus | typeof ALL_JOBS_STATUS) => void; +}> = ({ value, onChange }) => { + return ( +
+ + +
+ ); +}; + +const SelectedResourcesHoverList: React.FC<{ + releaseTargets: ReleaseTarget[]; +}> = ({ releaseTargets }) => { + const resources = releaseTargets.map(({ resource }) => resource); + + return ( + +
+ Redeploying to: + + + {resources.length} resources + + +
+ +
+ {resources.map((resource) => ( + + {resource.name} + + ))} +
+
+
+ ); +}; + +const useRedeployReleaseTargets = ( + environmentId: string, + releaseTargetIds: string[], + force: boolean, + onClose?: () => void, +) => { + const redeploy = api.redeploy.toEnvironment.useMutation(); + const router = useRouter(); + + const handleRedeploy = () => + redeploy + .mutateAsync({ environmentId, releaseTargetIds, force }) + .then(() => toast.success("Jobs queued successfully")) + .then(() => router.refresh()) + .then(() => onClose?.()); + + return { handleRedeploy, isPending: redeploy.isPending }; +}; + +export const RedeployReleaseTargetsDialog: React.FC<{ + environment: { id: string }; + releaseTargets: ReleaseTarget[]; + children: React.ReactNode; + onClose?: () => void; +}> = ({ environment, releaseTargets, children, onClose }) => { + const [open, setOpen] = useState(false); + const { selectedStatus, filteredReleaseTargets, onSelectStatus } = + useFilterByJobStatus(releaseTargets); + + const { handleRedeploy, isPending } = useRedeployReleaseTargets( + environment.id, + filteredReleaseTargets.map(({ id }) => id), + false, + onClose, + ); + + return ( + { + if (!open) onClose?.(); + setOpen(open); + }} + > + {children} + e.stopPropagation()}> + + Redeploy resources + + This will redeploy to the selected resources. + + + + + + + + + + + + + + + + + ); +}; + +export const ForceDeployReleaseTargetsDialog: React.FC<{ + environment: { id: string }; + releaseTargets: ReleaseTarget[]; + children: React.ReactNode; + onClose?: () => void; +}> = ({ environment, releaseTargets, children, onClose }) => { + const [open, setOpen] = useState(false); + const { selectedStatus, filteredReleaseTargets, onSelectStatus } = + useFilterByJobStatus(releaseTargets); + + const { handleRedeploy, isPending } = useRedeployReleaseTargets( + environment.id, + filteredReleaseTargets.map(({ id }) => id), + true, + onClose, + ); + + return ( + { + if (!open) onClose?.(); + setOpen(open); + }} + > + {children} + e.stopPropagation()}> + + Force deploy resources + + Are you sure? This will force deploy to the selected resources. + + + + + + + + + + + + + + + + + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/_components/deployment-version/ForceDeployVersion.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/_components/deployment-version/ForceDeployVersion.tsx index 1c5d47dc8..65c8ef384 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/_components/deployment-version/ForceDeployVersion.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/_components/deployment-version/ForceDeployVersion.tsx @@ -22,7 +22,7 @@ export const ForceDeployVersionDialog: React.FC = ({ resource, children, }) => { - const redeploy = api.redeploy.useMutation(); + const redeploy = api.redeploy.toReleaseTargets.useMutation(); const router = useRouter(); const environmentId = environment.id; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/_components/deployment-version/RedeployVersionDialog.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/_components/deployment-version/RedeployVersionDialog.tsx index 5dea1de17..2646a0559 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/_components/deployment-version/RedeployVersionDialog.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/_components/deployment-version/RedeployVersionDialog.tsx @@ -25,7 +25,7 @@ export const RedeployVersionDialog: React.FC = ({ children, }) => { const router = useRouter(); - const redeploy = api.redeploy.useMutation(); + const redeploy = api.redeploy.toReleaseTargets.useMutation(); const [isOpen, setIsOpen] = useState(false); const environmentId = environment.id; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/_components/deployments/dropdown/RedeployJobsDialog.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/_components/deployments/dropdown/RedeployJobsDialog.tsx index 11e6990cf..6993c3d1d 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/_components/deployments/dropdown/RedeployJobsDialog.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/_components/deployments/dropdown/RedeployJobsDialog.tsx @@ -23,7 +23,7 @@ export const RedeployJobsDialog: React.FC<{ const [open, setOpen] = useState(false); const router = useRouter(); - const redeployJobs = api.redeploy.useMutation(); + const redeployJobs = api.redeploy.toReleaseTargets.useMutation(); const handleRedeploy = () => redeployJobs diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index a406e8824..08128a08d 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -4,7 +4,7 @@ import { environmentRouter } from "./router/environment"; import { githubRouter } from "./router/github"; import { jobRouter } from "./router/job"; import { policyRouter } from "./router/policy/router"; -import { redeployProcedure } from "./router/redeploy"; +import { redeployRouter } from "./router/redeploy"; import { releaseTargetRouter } from "./router/release-target"; import { resourceSchemaRouter } from "./router/resource-schema"; import { resourceRouter } from "./router/resources"; @@ -34,10 +34,8 @@ export const appRouter = createTRPCRouter({ runtime: runtimeRouter, runbook: runbookRouter, policy: policyRouter, - search: searchRouter, - - redeploy: redeployProcedure, + redeploy: redeployRouter, }); // export type definition of API diff --git a/packages/api/src/router/redeploy.ts b/packages/api/src/router/redeploy.ts index 76ef7db9a..a7a714dc8 100644 --- a/packages/api/src/router/redeploy.ts +++ b/packages/api/src/router/redeploy.ts @@ -1,6 +1,6 @@ import type { Tx } from "@ctrlplane/db"; import { TRPCError } from "@trpc/server"; -import { desc } from "drizzle-orm"; +import { desc, inArray } from "drizzle-orm"; import { z } from "zod"; import { and, eq, takeFirstOrNull } from "@ctrlplane/db"; @@ -9,7 +9,7 @@ import * as schema from "@ctrlplane/db/schema"; import { Channel, dispatchQueueJob, getQueue } from "@ctrlplane/events"; import { Permission } from "@ctrlplane/validators/auth"; -import { protectedProcedure } from "../trpc"; +import { createTRPCRouter, protectedProcedure } from "../trpc"; const createForceDeployment = async ( db: Tx, @@ -62,7 +62,7 @@ const handleDeployment = async ( .releaseTargets(releaseTargets, { skipDuplicateCheck: true }); }; -export const redeployProcedure = protectedProcedure +const redeployProcedure = protectedProcedure .input( z .object({ @@ -128,3 +128,35 @@ export const redeployProcedure = protectedProcedure await handleDeployment(db, releaseTargets, input.force); return releaseTargets.length; }); + +const redeployToEnvironmentProcedure = protectedProcedure + .input( + z.object({ + environmentId: z.string().uuid(), + releaseTargetIds: z.array(z.string().uuid()), + force: z.boolean().optional().default(false), + }), + ) + .meta({ + authorizationCheck: ({ canUser, input }) => + canUser.perform(Permission.EnvironmentUpdate).on({ + type: "environment", + id: input.environmentId, + }), + }) + .mutation(async ({ ctx: { db }, input }) => { + const { releaseTargetIds, force } = input; + + const releaseTargets = await db.query.releaseTarget.findMany({ + where: inArray(schema.releaseTarget.id, releaseTargetIds), + }); + + if (releaseTargets.length === 0) return 0; + await handleDeployment(db, releaseTargets, force); + return releaseTargets.length; + }); + +export const redeployRouter = createTRPCRouter({ + toEnvironment: redeployToEnvironmentProcedure, + toReleaseTargets: redeployProcedure, +}); From 2bac845622987b75cdb0ef28e9fcc5d249b67eb3 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Thu, 3 Jul 2025 13:06:56 -0700 Subject: [PATCH 2/2] add validations --- .../jobs/_components/RedeployReleaseTargets.tsx | 7 +++++-- packages/api/src/router/redeploy.ts | 6 +++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/jobs/_components/RedeployReleaseTargets.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/jobs/_components/RedeployReleaseTargets.tsx index efe1bb404..20431b266 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/jobs/_components/RedeployReleaseTargets.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/jobs/_components/RedeployReleaseTargets.tsx @@ -187,7 +187,10 @@ export const RedeployReleaseTargetsDialog: React.FC<{ - @@ -242,7 +245,7 @@ export const ForceDeployReleaseTargetsDialog: React.FC<{ diff --git a/packages/api/src/router/redeploy.ts b/packages/api/src/router/redeploy.ts index a7a714dc8..200e12679 100644 --- a/packages/api/src/router/redeploy.ts +++ b/packages/api/src/router/redeploy.ts @@ -146,9 +146,13 @@ const redeployToEnvironmentProcedure = protectedProcedure }) .mutation(async ({ ctx: { db }, input }) => { const { releaseTargetIds, force } = input; + if (releaseTargetIds.length === 0) return 0; const releaseTargets = await db.query.releaseTarget.findMany({ - where: inArray(schema.releaseTarget.id, releaseTargetIds), + where: and( + inArray(schema.releaseTarget.id, releaseTargetIds), + eq(schema.releaseTarget.environmentId, input.environmentId), + ), }); if (releaseTargets.length === 0) return 0;