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
Expand Up @@ -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 = {
Expand Down Expand Up @@ -67,41 +69,49 @@ 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<JobActionsDropdownMenuProps> = (
props,
) => {
const [open, setOpen] = useState(false);
const { jobs } = props;
const utils = api.useUtils();

return (
<DropdownMenu>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<IconDots className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<RedeployVersionDialog {...props}>
<DropdownMenuContent onClick={(e) => e.stopPropagation()}>
<RedeployReleaseTargetsDialog {...props} onClose={() => setOpen(false)}>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
className="flex items-center gap-2"
>
<IconReload className="h-4 w-4" />
Redeploy
</DropdownMenuItem>
</RedeployVersionDialog>
<ForceDeployVersionDialog {...props}>
</RedeployReleaseTargetsDialog>
<ForceDeployReleaseTargetsDialog
{...props}
onClose={() => setOpen(false)}
>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
className="flex items-center gap-2"
>
<IconAlertTriangle className="h-4 w-4" />
Force deploy
</DropdownMenuItem>
</ForceDeployVersionDialog>
</ForceDeployReleaseTargetsDialog>
<OverrideJobStatusDialog
jobs={jobs}
onClose={() => utils.deployment.version.job.list.invalidate()}
Expand Down Expand Up @@ -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,
},
};
})}
/>
}
</TableCell>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
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<ReleaseTarget[]>(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,
};
};
Comment on lines +46 to +71
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

Add useEffect to sync filtered data when releaseTargets changes.

The hook maintains filteredReleaseTargets in local state but doesn't update it when the releaseTargets prop changes. This could lead to stale data if the parent component updates the array.

Add a useEffect to keep the filtered data in sync:

+import { useEffect } from "react";

 const useFilterByJobStatus = (releaseTargets: ReleaseTarget[]) => {
   const [selectedStatus, setSelectedStatus] = useState<
     JobStatus | typeof ALL_JOBS_STATUS
   >(ALL_JOBS_STATUS);
   const [filteredReleaseTargets, setFilteredReleaseTargets] =
     useState<ReleaseTarget[]>(releaseTargets);

+  useEffect(() => {
+    if (selectedStatus === ALL_JOBS_STATUS) {
+      setFilteredReleaseTargets(releaseTargets);
+    } else {
+      const filtered = releaseTargets.filter(
+        ({ latestJob }) => latestJob.status === selectedStatus,
+      );
+      setFilteredReleaseTargets(filtered);
+    }
+  }, [releaseTargets, selectedStatus]);
🤖 Prompt for AI Agents
In
apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/jobs/_components/RedeployReleaseTargets.tsx
between lines 46 and 71, the hook useFilterByJobStatus sets
filteredReleaseTargets state initially but does not update it when the
releaseTargets prop changes, causing stale data. Fix this by adding a useEffect
that listens for changes to releaseTargets and updates filteredReleaseTargets
accordingly, applying the current selectedStatus filter if it is not
ALL_JOBS_STATUS.


const JobStatusSelector: React.FC<{
value: JobStatus | typeof ALL_JOBS_STATUS;
onChange: (value: JobStatus | typeof ALL_JOBS_STATUS) => void;
}> = ({ value, onChange }) => {
return (
<div className="space-y-2">
<Label>Select jobs to override by status</Label>
<Select value={value} onValueChange={onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_JOBS_STATUS}>All statuses</SelectItem>
{Object.values(JobStatus).map((status) => (
<SelectItem key={status} value={status}>
{capitalCase(status)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};

const SelectedResourcesHoverList: React.FC<{
releaseTargets: ReleaseTarget[];
}> = ({ releaseTargets }) => {
const resources = releaseTargets.map(({ resource }) => resource);

return (
<HoverCard>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Redeploying to:</span>
<HoverCardTrigger asChild>
<Badge variant="secondary" className="h-7 text-xs">
{resources.length} resources
</Badge>
</HoverCardTrigger>
</div>
<HoverCardContent className="w-80 p-2" align="center" side="right">
<div className="flex flex-col gap-2">
{resources.map((resource) => (
<span
key={resource.id}
className="truncate text-sm text-muted-foreground"
>
{resource.name}
</span>
))}
</div>
</HoverCardContent>
</HoverCard>
);
};

const useRedeployReleaseTargets = (
environmentId: string,
releaseTargetIds: string[],
force: boolean,
onClose?: () => void,
) => {
const redeploy = api.redeploy.toEnvironment.useMutation();
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

Use the correct API endpoint for redeploying release targets.

The hook should use the new toReleaseTargets endpoint instead of toEnvironment to match the backend API changes.

Apply this fix:

-  const redeploy = api.redeploy.toEnvironment.useMutation();
+  const redeploy = api.redeploy.toReleaseTargets.useMutation();
📝 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 redeploy = api.redeploy.toEnvironment.useMutation();
const redeploy = api.redeploy.toReleaseTargets.useMutation();
🤖 Prompt for AI Agents
In
apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/jobs/_components/RedeployReleaseTargets.tsx
at line 134, the redeploy hook is using the outdated api.redeploy.toEnvironment
endpoint. Update this line to use api.redeploy.toReleaseTargets instead to align
with the backend API changes.

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 (
<Dialog
open={open}
onOpenChange={(open) => {
if (!open) onClose?.();
setOpen(open);
}}
>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="space-y-4" onClick={(e) => e.stopPropagation()}>
<DialogHeader>
<DialogTitle>Redeploy resources</DialogTitle>
<DialogDescription>
This will redeploy to the selected resources.
</DialogDescription>
</DialogHeader>

<JobStatusSelector value={selectedStatus} onChange={onSelectStatus} />

<SelectedResourcesHoverList releaseTargets={filteredReleaseTargets} />

<DialogFooter className="flex justify-between sm:justify-between">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>

<Button
onClick={handleRedeploy}
disabled={isPending || filteredReleaseTargets.length === 0}
>
Redeploy
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

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 (
<Dialog
open={open}
onOpenChange={(open) => {
if (!open) onClose?.();
setOpen(open);
}}
>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="space-y-4" onClick={(e) => e.stopPropagation()}>
<DialogHeader>
<DialogTitle>Force deploy resources</DialogTitle>
<DialogDescription>
Are you sure? This will force deploy to the selected resources.
</DialogDescription>
</DialogHeader>

<JobStatusSelector value={selectedStatus} onChange={onSelectStatus} />

<SelectedResourcesHoverList releaseTargets={filteredReleaseTargets} />

<DialogFooter className="flex justify-between sm:justify-between">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>

<Button
variant="destructive"
onClick={handleRedeploy}
disabled={isPending || filteredReleaseTargets.length === 0}
>
Force deploy
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const ForceDeployVersionDialog: React.FC<DeployProps> = ({
resource,
children,
}) => {
const redeploy = api.redeploy.useMutation();
const redeploy = api.redeploy.toReleaseTargets.useMutation();
const router = useRouter();

const environmentId = environment.id;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const RedeployVersionDialog: React.FC<DeployProps> = ({
children,
}) => {
const router = useRouter();
const redeploy = api.redeploy.useMutation();
const redeploy = api.redeploy.toReleaseTargets.useMutation();
const [isOpen, setIsOpen] = useState(false);

const environmentId = environment.id;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 2 additions & 4 deletions packages/api/src/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading