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
11 changes: 7 additions & 4 deletions apps/jobs/src/policy-checker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const run = async () => {
const isPassingApprovalGate = or(
isNull(schema.environment.policyId),
eq(schema.environmentPolicy.approvalRequirement, "automatic"),
eq(schema.environmentApproval.status, "approved"),
eq(schema.environmentPolicyApproval.status, "approved"),
);

const releaseJobTriggers = await db
Expand All @@ -29,11 +29,14 @@ export const run = async () => {
eq(schema.environment.policyId, schema.environmentPolicy.id),
)
.leftJoin(
schema.environmentApproval,
schema.environmentPolicyApproval,
and(
eq(schema.environmentApproval.environmentId, schema.environment.id),
eq(
schema.environmentApproval.releaseId,
schema.environmentPolicyApproval.policyId,
schema.environmentPolicy.id,
),
eq(
schema.environmentPolicyApproval.releaseId,
schema.releaseJobTrigger.releaseId,
),
),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { Environment } from "@ctrlplane/db/schema";
import { useState } from "react";
import { useRouter } from "next/navigation";

import {
Expand All @@ -11,35 +13,56 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@ctrlplane/ui/alert-dialog";
import { Badge } from "@ctrlplane/ui/badge";
import { Button } from "@ctrlplane/ui/button";

import { api } from "~/trpc/react";
import { Cancelled, Failing, Loading, Passing, Waiting } from "./StatusIcons";

const ApprovalDialog: React.FC<{
releaseId: string;
environmentId: string;
export const ApprovalDialog: React.FC<{
release: { id: string; version: string };
policyId: string;
linkedEnvironments: Array<Environment>;
children: React.ReactNode;
}> = ({ releaseId, environmentId, children }) => {
const approve = api.environment.approval.approve.useMutation();
const rejected = api.environment.approval.reject.useMutation();
}> = ({ release, policyId, linkedEnvironments, children }) => {
const [open, setOpen] = useState(false);
const approve = api.environment.policy.approval.approve.useMutation();
const reject = api.environment.policy.approval.reject.useMutation();
const releaseId = release.id;
const onApprove = () =>
approve
.mutateAsync({ releaseId, environmentId })
.then(() => router.refresh());
.mutateAsync({ releaseId, policyId })
.then(() => router.refresh())
.then(() => setOpen(false));
const onReject = () =>
rejected
.mutateAsync({ releaseId, environmentId })
.then(() => router.refresh());
reject
.mutateAsync({ releaseId, policyId })
.then(() => router.refresh())
.then(() => setOpen(false));
const router = useRouter();
return (
<AlertDialog>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Approval</AlertDialogTitle>
<AlertDialogTitle className="text-xl font-semibold">
Approve release <span className="truncate">{release.version}</span>
</AlertDialogTitle>
<AlertDialogDescription>
Approving this action will initiate the deployment of the release to
all currently linked environments.
<div className="flex flex-col gap-2">
Approves this release for the following environments:
<div className="flex flex-wrap gap-2">
{linkedEnvironments.map((env) => (
<Badge
key={env.id}
variant="secondary"
className="max-w-24 truncate"
>
{env.name}
</Badge>
))}
</div>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
Expand All @@ -52,13 +75,14 @@ const ApprovalDialog: React.FC<{
};

export const ApprovalCheck: React.FC<{
environmentId: string;
releaseId: string;
}> = ({ environmentId, releaseId }) => {
policyId: string;
release: { id: string; version: string };
linkedEnvironments: Array<Environment>;
}> = ({ policyId, release, linkedEnvironments }) => {
const approvalStatus =
api.environment.approval.statusByReleaseEnvironmentId.useQuery({
environmentId,
releaseId,
api.environment.policy.approval.statusByReleasePolicyId.useQuery({
policyId,
releaseId: release.id,
});

if (approvalStatus.isLoading)
Expand All @@ -77,25 +101,36 @@ export const ApprovalCheck: React.FC<{

const status = approvalStatus.data.status;
return (
<ApprovalDialog environmentId={environmentId} releaseId={releaseId}>
<button
disabled={status === "approved" || status === "rejected"}
className="flex w-full items-center gap-2 rounded-md hover:bg-neutral-800/50"
>
{status === "approved" ? (
<div className="flex w-full items-center justify-between gap-2">
<div className="flex items-center gap-2">
{status === "approved" && (
<>
<Passing /> Approved
</>
) : status === "rejected" ? (
)}
{status === "rejected" && (
<>
<Failing /> Rejected
</>
) : (
)}
{status === "pending" && (
<>
<Waiting /> Pending approval
</>
)}
</button>
</ApprovalDialog>
</div>

{status === "pending" && (
<ApprovalDialog
policyId={policyId}
release={release}
linkedEnvironments={linkedEnvironments}
>
<Button size="sm" className="h-6 px-2 py-1">
Review
</Button>
</ApprovalDialog>
)}
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,90 +2,46 @@

import type {
Environment,
EnvironmentApproval,
EnvironmentPolicyApproval,
User,
} from "@ctrlplane/db/schema";
import { useRouter } from "next/navigation";

import { Button } from "@ctrlplane/ui/button";
import { toast } from "@ctrlplane/ui/toast";

import { api } from "~/trpc/react";
import { ApprovalDialog } from "./ApprovalCheck";

type EnvironmentApprovalRowProps = {
approval: EnvironmentApproval & { user?: User | null };
environment?: Environment;
approval: EnvironmentPolicyApproval & { user?: User | null };
release: { id: string; version: string };
linkedEnvironments: Environment[];
};

export const EnvironmentApprovalRow: React.FC<EnvironmentApprovalRowProps> = ({
approval,
environment,
release,
linkedEnvironments,
}) => {
const router = useRouter();
const utils = api.useUtils();

if (!environment) {
console.error("Environment is undefined for approval:", approval);
return null;
}

const environmentName = environment.name;
const { releaseId, environmentId, status } = approval;

const rejectMutation = api.environment.approval.reject.useMutation({
onSuccess: ({ cancelledJobCount }) => {
router.refresh();
utils.environment.policy.invalidate();
utils.job.config.invalidate();
toast.success(
`Rejected release to ${environmentName} and cancelled ${cancelledJobCount} job${cancelledJobCount !== 1 ? "s" : ""}`,
);
},
onError: () => toast.error("Error rejecting release"),
});

const approveMutation = api.environment.approval.approve.useMutation({
onSuccess: () => {
router.refresh();
utils.environment.policy.invalidate();
utils.job.config.invalidate();
toast.success(`Approved release to ${environmentName}`);
},
onError: () => toast.error("Error approving release"),
});

const handleReject = () =>
rejectMutation.mutate({ releaseId, environmentId });
const handleApprove = () =>
approveMutation.mutate({ releaseId, environmentId });
if (approval.status === "pending")
return (
<ApprovalDialog
release={release}
policyId={approval.policyId}
linkedEnvironments={linkedEnvironments}
>
<Button size="sm" className="h-6">
Review
</Button>
</ApprovalDialog>
);

return (
<div className="flex items-center gap-2 rounded-md text-sm">
{status === "pending" ? (
<div className="flex items-center gap-2">
<Button
variant="secondary"
className="h-6 px-2"
onClick={handleReject}
>
Reject
</Button>
<Button className="h-6 px-2" onClick={handleApprove}>
Approve
</Button>
</div>
<div className="ml-2 flex flex-grow items-center gap-2 rounded-md text-sm font-medium">
{approval.status === "approved" ? (
<span className="text-green-300">Approved</span>
) : (
<div className="ml-2 flex-grow">
<span className="font-medium">
{status === "approved" ? (
<span className="text-green-300">Approved</span>
) : (
<span className="text-red-300">Rejected</span>
)}{" "}
by {approval.user?.name}
</span>
</div>
)}
<span className="text-red-300">Rejected</span>
)}{" "}
{approval.user?.name ? `by ${approval.user.name}` : null}
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import { EnvironmentPolicyDrawerTab } from "~/app/[workspaceSlug]/(app)/_compone
import { useReleaseChannelDrawer } from "~/app/[workspaceSlug]/(app)/_components/release-channel-drawer/useReleaseChannelDrawer";
import { useQueryParams } from "~/app/[workspaceSlug]/(app)/_components/useQueryParams";
import { api } from "~/trpc/react";
import { ApprovalCheck } from "./ApprovalCheck";
import { Cancelled, Failing, Loading, Passing, Waiting } from "./StatusIcons";

type EnvironmentNodeProps = NodeProps<{
Expand Down Expand Up @@ -329,7 +328,6 @@ export const EnvironmentNode: React.FC<EnvironmentNodeProps> = ({ data }) => (
<WaitingOnActiveCheck {...data} />
<ReleaseChannelCheck {...data} />
<MinReleaseIntervalCheck {...data} />
<ApprovalCheck {...data} />
</div>
</div>
<Handle
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export const FlowDiagram: React.FC<{
policyDeployments: policyDeployments.filter(
(p) => p.policyId === policy.id,
),
linkedEnvironments: envs.filter((e) => e.policyId === policy.id),
label: policy.name,
release,
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
Environment,
EnvironmentPolicy,
EnvironmentPolicyDeployment,
Release,
Expand All @@ -15,12 +16,14 @@ import { cn } from "@ctrlplane/ui";
import { JobStatus } from "@ctrlplane/validators/jobs";

import { api } from "~/trpc/react";
import { ApprovalCheck } from "./ApprovalCheck";
import { Passing, Waiting } from "./StatusIcons";

type PolicyNodeProps = NodeProps<
EnvironmentPolicy & {
release: Release;
policyDeployments: Array<EnvironmentPolicyDeployment>;
linkedEnvironments: Array<Environment>;
}
>;

Expand Down Expand Up @@ -99,6 +102,7 @@ const GradualRolloutCheck: React.FC<PolicyNodeProps["data"]> = (data) => {
export const PolicyNode: React.FC<PolicyNodeProps> = ({ data }) => {
const noMinSuccess = data.successType === "optional";
const noRollout = data.rolloutDuration === 0;
const noApproval = data.approvalRequirement === "automatic";

return (
<>
Expand All @@ -109,8 +113,15 @@ export const PolicyNode: React.FC<PolicyNodeProps> = ({ data }) => {
>
{!noMinSuccess && <MinSuccessCheck {...data} />}
{!noRollout && <GradualRolloutCheck {...data} />}
{!noApproval && (
<ApprovalCheck
policyId={data.id}
release={data.release}
linkedEnvironments={data.linkedEnvironments}
/>
)}

{noMinSuccess && noRollout && (
{noMinSuccess && noRollout && noApproval && (
<div className="text-muted-foreground">No policy checks.</div>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,13 @@ const CollapsibleTableRow: React.FC<CollapsibleTableRowProps> = ({
}) => {
const { setJobId } = useJobDrawer();

const approvalsQ = api.environment.approval.byReleaseId.useQuery({
const approvalsQ = api.environment.policy.approval.byReleaseId.useQuery({
releaseId: release.id,
});

const approvals = approvalsQ.data ?? [];
const environmentApprovals = approvals.filter(
(approval) => approval.environmentId === environment.id,
(a) => a.policyId === environment.policyId,
);

const allTriggers = Object.values(triggersByResource).flat();
Expand All @@ -80,6 +80,13 @@ const CollapsibleTableRow: React.FC<CollapsibleTableRowProps> = ({
Record<string, boolean>
>({});

const environmentPolicyQ = api.environment.policy.byId.useQuery(
environment.policyId ?? "",
{ enabled: environment.policyId != null },
);

const linkedEnvironments = environmentPolicyQ.data?.environments ?? [];

const switchResourceExpandedState = (resourceId: string) =>
setExpandedResources((prev) => {
const newState = { ...prev };
Expand Down Expand Up @@ -145,7 +152,8 @@ const CollapsibleTableRow: React.FC<CollapsibleTableRowProps> = ({
<EnvironmentApprovalRow
key={approval.id}
approval={approval}
environment={environment}
release={release}
linkedEnvironments={linkedEnvironments}
/>
))}
</div>
Expand Down
Loading
Loading