From 53dd428423abae526d1c99b7b617aa469fedabe7 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Sat, 26 Oct 2024 15:30:08 -0700 Subject: [PATCH 1/4] fix: Init job filters --- .../[workspaceSlug]/(job)/jobs/JobTable.tsx | 135 +++++++ .../app/[workspaceSlug]/(job)/jobs/page.tsx | 47 +-- .../_components/JobTableStatusIcon.tsx | 4 + .../filter/ChoiceConditionRender.tsx | 25 +- .../filter/VersionConditionRender.tsx | 64 +++ .../DeploymentConditionRender.tsx | 71 ++++ .../EnvironmentConditionRender.tsx | 68 ++++ .../JobComparisonConditionRender.tsx | 380 ++++++++++++++++++ .../job-condition/JobConditionBadge.tsx | 300 ++++++++++++++ .../job-condition/JobConditionDialog.tsx | 87 ++++ .../job-condition/JobConditionRender.tsx | 100 +++++ .../JobCreatedAtConditionRender.tsx | 34 ++ .../JobMetadataConditionRender.tsx | 27 ++ .../job-condition/StatusConditionRender.tsx | 37 ++ .../job-condition/VersionConditionRender.tsx | 26 ++ .../job-condition/job-condition-props.ts | 9 + .../_components/job-condition/useJobFilter.ts | 40 ++ .../ComparisonConditionRender.tsx | 3 +- .../ReleaseConditionBadge.tsx | 11 +- .../ReleaseConditionRender.tsx | 6 +- ...sx => ReleaseCreatedAtConditionRender.tsx} | 11 +- .../VersionConditionRender.tsx | 70 +--- .../target-condition/KindConditionRender.tsx | 3 + .../ProviderConditionRender.tsx | 3 + .../releases/[versionId]/FlowNode.tsx | 2 +- .../releases/[versionId]/FlowPolicyNode.tsx | 7 +- .../releases/[versionId]/JobsTable.tsx | 7 +- .../[versionId]/TargetReleaseTable.tsx | 240 ++++++----- .../releases/[versionId]/page.tsx | 8 - packages/api/src/router/deployment.ts | 16 + packages/api/src/router/environment.ts | 20 + packages/api/src/router/job.ts | 110 ++++- packages/db/src/schema/job.ts | 130 +++++- packages/db/src/schema/release.ts | 22 +- packages/validators/src/auth/index.ts | 1 + .../src/conditions/date-condition.ts | 6 + packages/validators/src/conditions/index.ts | 22 + .../conditions/version-condition.ts | 8 + .../jobs/conditions/comparison-condition.ts | 52 +++ .../jobs/conditions/deployment-condition.ts | 9 + .../jobs/conditions/environment-condition.ts | 9 + .../validators/src/jobs/conditions/index.ts | 5 + .../src/jobs/conditions/job-condition.ts | 116 ++++++ .../src/jobs/conditions/status-condition.ts | 22 + packages/validators/src/jobs/index.ts | 14 + .../conditions/comparison-condition.ts | 4 +- .../src/releases/conditions/index.ts | 1 - .../releases/conditions/release-condition.ts | 4 +- 48 files changed, 2118 insertions(+), 278 deletions(-) create mode 100644 apps/webservice/src/app/[workspaceSlug]/(job)/jobs/JobTable.tsx create mode 100644 apps/webservice/src/app/[workspaceSlug]/_components/filter/VersionConditionRender.tsx create mode 100644 apps/webservice/src/app/[workspaceSlug]/_components/job-condition/DeploymentConditionRender.tsx create mode 100644 apps/webservice/src/app/[workspaceSlug]/_components/job-condition/EnvironmentConditionRender.tsx create mode 100644 apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobComparisonConditionRender.tsx create mode 100644 apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobConditionBadge.tsx create mode 100644 apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobConditionDialog.tsx create mode 100644 apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobConditionRender.tsx create mode 100644 apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobCreatedAtConditionRender.tsx create mode 100644 apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobMetadataConditionRender.tsx create mode 100644 apps/webservice/src/app/[workspaceSlug]/_components/job-condition/StatusConditionRender.tsx create mode 100644 apps/webservice/src/app/[workspaceSlug]/_components/job-condition/VersionConditionRender.tsx create mode 100644 apps/webservice/src/app/[workspaceSlug]/_components/job-condition/job-condition-props.ts create mode 100644 apps/webservice/src/app/[workspaceSlug]/_components/job-condition/useJobFilter.ts rename apps/webservice/src/app/[workspaceSlug]/_components/release-condition/{CreatedAtConditionRender.tsx => ReleaseCreatedAtConditionRender.tsx} (79%) rename packages/validators/src/{releases => }/conditions/version-condition.ts (64%) create mode 100644 packages/validators/src/jobs/conditions/comparison-condition.ts create mode 100644 packages/validators/src/jobs/conditions/deployment-condition.ts create mode 100644 packages/validators/src/jobs/conditions/environment-condition.ts create mode 100644 packages/validators/src/jobs/conditions/index.ts create mode 100644 packages/validators/src/jobs/conditions/job-condition.ts create mode 100644 packages/validators/src/jobs/conditions/status-condition.ts diff --git a/apps/webservice/src/app/[workspaceSlug]/(job)/jobs/JobTable.tsx b/apps/webservice/src/app/[workspaceSlug]/(job)/jobs/JobTable.tsx new file mode 100644 index 000000000..54e3a6f0b --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(job)/jobs/JobTable.tsx @@ -0,0 +1,135 @@ +"use client"; + +import React from "react"; +import { IconFilter, IconLoader2 } from "@tabler/icons-react"; +import _ from "lodash"; + +import { Badge } from "@ctrlplane/ui/badge"; +import { Button } from "@ctrlplane/ui/button"; +import { Skeleton } from "@ctrlplane/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@ctrlplane/ui/table"; +import { JobStatusReadable } from "@ctrlplane/validators/jobs"; + +import { NoFilterMatch } from "~/app/[workspaceSlug]/_components/filter/NoFilterMatch"; +import { JobConditionBadge } from "~/app/[workspaceSlug]/_components/job-condition/JobConditionBadge"; +import { JobConditionDialog } from "~/app/[workspaceSlug]/_components/job-condition/JobConditionDialog"; +import { useJobFilter } from "~/app/[workspaceSlug]/_components/job-condition/useJobFilter"; +import { useJobDrawer } from "~/app/[workspaceSlug]/_components/job-drawer/useJobDrawer"; +import { JobTableStatusIcon } from "~/app/[workspaceSlug]/_components/JobTableStatusIcon"; +import { api } from "~/trpc/react"; + +type JobTableProps = { + workspaceId: string; +}; + +export const JobTable: React.FC = ({ workspaceId }) => { + const { filter, setFilter } = useJobFilter(); + const { setJobId } = useJobDrawer(); + const allReleaseJobTriggers = api.job.config.byWorkspaceId.list.useQuery( + { workspaceId }, + { refetchInterval: 60_000, placeholderData: (prev) => prev }, + ); + + const releaseJobTriggers = api.job.config.byWorkspaceId.list.useQuery( + { workspaceId, filter }, + { refetchInterval: 10_000, placeholderData: (prev) => prev }, + ); + + return ( +
+
+
+ +
+ + {filter != null && } +
+
+ {!releaseJobTriggers.isLoading && releaseJobTriggers.isFetching && ( + + )} +
+ + {releaseJobTriggers.data?.total != null && ( +
+ Total: + + {releaseJobTriggers.data.total} + +
+ )} +
+ + {releaseJobTriggers.isLoading && ( +
+ {_.range(10).map((i) => ( + + ))} +
+ )} + {releaseJobTriggers.isSuccess && releaseJobTriggers.data.total === 0 && ( + setFilter(undefined)} + /> + )} + + {releaseJobTriggers.isSuccess && releaseJobTriggers.data.total > 0 && ( +
+ + + + Target + Environment + Deployment + Status + Release Version + + + + {releaseJobTriggers.data.items.map((job) => ( + setJobId(job.job.id)} + className="cursor-pointer" + > + {job.target.name} + {job.environment.name} + {job.release.deployment.name} + +
+ + {JobStatusReadable[job.job.status]} +
+
+ {job.release.version} +
+ ))} +
+
+
+ )} +
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(job)/jobs/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(job)/jobs/page.tsx index fe9ca9841..f09501699 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(job)/jobs/page.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(job)/jobs/page.tsx @@ -1,17 +1,8 @@ import { notFound } from "next/navigation"; -import { format } from "date-fns"; - -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@ctrlplane/ui/table"; import { api } from "~/trpc/server"; import { JobsGettingStarted } from "./JobsGettingStarted"; +import { JobTable } from "./JobTable"; export default async function JobsPage({ params, @@ -21,37 +12,11 @@ export default async function JobsPage({ const workspace = await api.workspace.bySlug(params.workspaceSlug); if (workspace == null) return notFound(); - const releaseJobTriggers = await api.job.config.byWorkspaceId.list( - workspace.id, - ); + const releaseJobTriggers = await api.job.config.byWorkspaceId.list({ + workspaceId: workspace.id, + }); - if (releaseJobTriggers.length === 0) return ; + if (releaseJobTriggers.total === 0) return ; - return ( -
-

Jobs

- - - - Environment - Target - Release Version - Type - Created At - - - - {releaseJobTriggers.map((job) => ( - - {job.environment.name} - {job.target.name} - {job.release.version} - {job.type} - {format(new Date(job.createdAt), "PPpp")} - - ))} - -
-
- ); + return ; } diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/JobTableStatusIcon.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/JobTableStatusIcon.tsx index 47bc1b6b7..f75caf281 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/JobTableStatusIcon.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/JobTableStatusIcon.tsx @@ -27,6 +27,10 @@ export const JobTableStatusIcon: React.FC<{ ); + if (status === JobStatus.Cancelled) + return ( + + ); return ; }; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/filter/ChoiceConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/filter/ChoiceConditionRender.tsx index f82f59348..529082d96 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/filter/ChoiceConditionRender.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/filter/ChoiceConditionRender.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { IconSelector } from "@tabler/icons-react"; +import { IconLoader2, IconSelector } from "@tabler/icons-react"; import { capitalCase } from "change-case"; import { cn } from "@ctrlplane/ui"; @@ -18,6 +18,7 @@ type ChoiceConditionRenderProps = { type: string; selected: string | null; options: { key: string; value: string; display: string }[]; + loading?: boolean; className?: string; }; @@ -26,6 +27,7 @@ export const ChoiceConditionRender: React.FC = ({ type, selected, options, + loading = false, className, }) => { const [open, setOpen] = useState(false); @@ -34,7 +36,7 @@ export const ChoiceConditionRender: React.FC = ({
- {capitalCase(type)} + {capitalCase(type)} is
@@ -46,9 +48,7 @@ export const ChoiceConditionRender: React.FC = ({ className="w-full items-center justify-start gap-2 rounded-l-none rounded-r-md bg-transparent px-2 hover:bg-neutral-800/50" > - + {selected ?? `Select ${type}...`} @@ -58,15 +58,24 @@ export const ChoiceConditionRender: React.FC = ({ - {options.length === 0 && ( + {loading && ( + + + Loading {type}s... + + )} + {!loading && options.length === 0 && ( No options to add )} {options.map((option) => ( { - onSelect(option.key); + onSelect(option.value); setOpen(false); }} > diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/filter/VersionConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/filter/VersionConditionRender.tsx new file mode 100644 index 000000000..261934d2c --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/filter/VersionConditionRender.tsx @@ -0,0 +1,64 @@ +import type { VersionOperatorType } from "@ctrlplane/validators/conditions"; + +import { cn } from "@ctrlplane/ui"; +import { Input } from "@ctrlplane/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@ctrlplane/ui/select"; +import { VersionOperator } from "@ctrlplane/validators/conditions"; + +type VersionConditionRenderProps = { + operator: VersionOperatorType; + value: string; + setOperator: (operator: VersionOperatorType) => void; + setValue: (value: string) => void; + className?: string; + title?: string; +}; + +export const VersionConditionRender: React.FC = ({ + operator, + value, + setOperator, + setValue, + className, + title = "Version", +}) => ( +
+
+
+ {title} +
+
+ +
+
+ setValue(e.target.value)} + className="w-full cursor-pointer rounded-l-none" + /> +
+
+
+); diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/DeploymentConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/DeploymentConditionRender.tsx new file mode 100644 index 000000000..d8f2cd9c6 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/DeploymentConditionRender.tsx @@ -0,0 +1,71 @@ +import type { DeploymentCondition } from "@ctrlplane/validators/jobs"; +import type React from "react"; +import { useParams } from "next/navigation"; + +import type { JobConditionRenderProps } from "./job-condition-props"; +import { api } from "~/trpc/react"; +import { ChoiceConditionRender } from "../filter/ChoiceConditionRender"; + +export const DeploymentConditionRender: React.FC< + JobConditionRenderProps +> = ({ condition, onChange, className }) => { + const { workspaceSlug, systemSlug, deploymentSlug } = useParams<{ + workspaceSlug: string; + systemSlug?: string; + deploymentSlug?: string; + }>(); + + const workspaceQ = api.workspace.bySlug.useQuery(workspaceSlug); + const workspace = workspaceQ.data; + + const workspaceDeploymentsQ = api.deployment.byWorkspaceId.useQuery( + workspace?.id ?? "", + { enabled: workspace != null }, + ); + const workspaceDeployments = workspaceDeploymentsQ.data; + + const isEnabled = + workspace != null && systemSlug != null && deploymentSlug != null; + + const deploymentQ = api.deployment.bySlug.useQuery( + { + workspaceSlug: workspaceSlug, + systemSlug: systemSlug ?? "", + deploymentSlug: deploymentSlug ?? "", + }, + { enabled: isEnabled }, + ); + const deployment = deploymentQ.data; + + const deployments = + deployment != null ? [deployment] : (workspaceDeployments ?? []); + + const options = deployments.map((deployment) => ({ + key: deployment.id, + value: deployment.id, + display: deployment.name, + })); + + const setDeployment = (deploymentId: string) => + onChange({ ...condition, value: deploymentId }); + + const selectedDeployment = deployments.find( + (deployment) => deployment.id === condition.value, + ); + + const loading = + workspaceQ.isLoading || + workspaceDeploymentsQ.isLoading || + deploymentQ.isLoading; + + return ( + + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/EnvironmentConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/EnvironmentConditionRender.tsx new file mode 100644 index 000000000..c46c490b6 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/EnvironmentConditionRender.tsx @@ -0,0 +1,68 @@ +import type { EnvironmentCondition } from "@ctrlplane/validators/jobs"; +import { useParams } from "next/navigation"; + +import type { JobConditionRenderProps } from "./job-condition-props"; +import { api } from "~/trpc/react"; +import { ChoiceConditionRender } from "../filter/ChoiceConditionRender"; + +export const EnvironmentConditionRender: React.FC< + JobConditionRenderProps +> = ({ condition, onChange, className }) => { + const { workspaceSlug, systemSlug } = useParams<{ + workspaceSlug: string; + systemSlug?: string; + }>(); + + const workspaceQ = api.workspace.bySlug.useQuery(workspaceSlug); + const workspace = workspaceQ.data; + + const systemQ = api.system.bySlug.useQuery( + { workspaceSlug: workspaceSlug, systemSlug: systemSlug ?? "" }, + { enabled: workspace != null && systemSlug != null }, + ); + const system = systemQ.data; + + const workspaceEnvironmentsQ = api.environment.byWorkspaceId.useQuery( + workspace?.id ?? "", + { enabled: workspace != null }, + ); + const workspaceEnvironments = workspaceEnvironmentsQ.data; + + const systemEnvironmentsQ = api.environment.bySystemId.useQuery( + system?.id ?? "", + { enabled: system != null }, + ); + const systemEnvironments = systemEnvironmentsQ.data; + + const environments = systemEnvironments ?? workspaceEnvironments ?? []; + + const loading = + workspaceQ.isLoading || + systemQ.isLoading || + workspaceEnvironmentsQ.isLoading || + systemEnvironmentsQ.isLoading; + + const options = environments.map((environment) => ({ + key: environment.id, + value: environment.id, + display: environment.name, + })); + + const setEnvironment = (environment: string) => + onChange({ ...condition, value: environment }); + + const selectedEnvironment = environments.find( + (environment) => environment.id === condition.value, + ); + + return ( + + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobComparisonConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobComparisonConditionRender.tsx new file mode 100644 index 000000000..0bc279cfd --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobComparisonConditionRender.tsx @@ -0,0 +1,380 @@ +import type { + ComparisonCondition, + JobCondition, +} from "@ctrlplane/validators/jobs"; +import { + IconChevronDown, + IconCopy, + IconDots, + IconEqualNot, + IconPlus, + IconRefresh, + IconTrash, +} from "@tabler/icons-react"; +import { capitalCase } from "change-case"; + +import { cn } from "@ctrlplane/ui"; +import { Button } from "@ctrlplane/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@ctrlplane/ui/dropdown-menu"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@ctrlplane/ui/select"; +import { + ColumnOperator, + ComparisonOperator, + DateOperator, + FilterType, + MetadataOperator, + VersionOperator, +} from "@ctrlplane/validators/conditions"; +import { + doesConvertingToComparisonRespectMaxDepth, + isComparisonCondition, + JobFilterType, + JobStatus, +} from "@ctrlplane/validators/jobs"; + +import type { JobConditionRenderProps } from "./job-condition-props"; +import { JobConditionRender } from "./JobConditionRender"; + +export const JobComparisonConditionRender: React.FC< + JobConditionRenderProps +> = ({ condition, onChange, depth = 0, className }) => { + const setOperator = ( + operator: ComparisonOperator.And | ComparisonOperator.Or, + ) => onChange({ ...condition, operator }); + + const updateCondition = (index: number, changedCondition: JobCondition) => + onChange({ + ...condition, + conditions: condition.conditions.map((c, i) => + i === index ? changedCondition : c, + ), + }); + + const addCondition = (changedCondition: JobCondition) => + onChange({ + ...condition, + conditions: [...condition.conditions, changedCondition], + }); + + const removeCondition = (index: number) => + onChange({ + ...condition, + conditions: condition.conditions.filter((_, i) => i !== index), + }); + + const convertToComparison = (index: number) => { + const cond = condition.conditions[index]; + if (!cond) return; + + const newComparisonCondition: ComparisonCondition = { + type: FilterType.Comparison, + operator: ComparisonOperator.And, + conditions: [cond], + }; + + const newCondition = { + ...condition, + conditions: condition.conditions.map((c, i) => + i === index ? newComparisonCondition : c, + ), + }; + onChange(newCondition); + }; + + const convertToNotComparison = (index: number) => { + const cond = condition.conditions[index]; + if (!cond) return; + + if (isComparisonCondition(cond)) { + const currentNot = cond.not ?? false; + const newNotSubcondition = { + ...cond, + not: !currentNot, + }; + const newCondition = { + ...condition, + conditions: condition.conditions.map((c, i) => + i === index ? newNotSubcondition : c, + ), + }; + onChange(newCondition); + return; + } + + const newNotComparisonCondition: ComparisonCondition = { + type: FilterType.Comparison, + operator: ComparisonOperator.And, + not: true, + conditions: [cond], + }; + + const newCondition = { + ...condition, + conditions: condition.conditions.map((c, i) => + i === index ? newNotComparisonCondition : c, + ), + }; + onChange(newCondition); + }; + + const clear = () => onChange({ ...condition, conditions: [] }); + + const not = condition.not ?? false; + + return ( +
+ {condition.conditions.length === 0 && ( + + {not ? "Empty not group" : "No conditions"} + + )} +
+ {condition.conditions.map((subCond, index) => ( +
+
+ {index !== 1 && ( +
+ {index !== 0 && capitalCase(condition.operator)} + {index === 0 && !condition.not && "When"} + {index === 0 && condition.not && "Not"} +
+ )} + {index === 1 && ( + + )} + updateCondition(index, c)} + onRemove={() => removeCondition(index)} + depth={depth + 1} + className={cn(depth === 0 ? "col-span-11" : "col-span-10")} + /> +
+ + + + + + + removeCondition(index)} + className="flex items-center gap-2" + > + + Remove + + addCondition(subCond)} + className="flex items-center gap-2" + > + + Duplicate + + {doesConvertingToComparisonRespectMaxDepth( + depth + 1, + subCond, + ) && ( + convertToComparison(index)} + className="flex items-center gap-2" + > + + Turn into group + + )} + {(isComparisonCondition(subCond) || + doesConvertingToComparisonRespectMaxDepth( + depth + 1, + subCond, + )) && ( + convertToNotComparison(index)} + className="flex items-center gap-2" + > + + Negate condition + + )} + + +
+ ))} +
+ +
+ + + + + + + + addCondition({ + type: FilterType.Metadata, + operator: MetadataOperator.Equals, + key: "", + value: "", + }) + } + > + Metadata + + + addCondition({ + type: FilterType.CreatedAt, + operator: DateOperator.Before, + value: new Date().toISOString(), + }) + } + > + Created at + + + addCondition({ + type: JobFilterType.Status, + operator: ColumnOperator.Equals, + value: JobStatus.Completed, + }) + } + > + Status + + + addCondition({ + type: JobFilterType.Deployment, + operator: ColumnOperator.Equals, + value: "", + }) + } + > + Deployment + + + addCondition({ + type: JobFilterType.Environment, + operator: ColumnOperator.Equals, + value: "", + }) + } + > + Environment + + + addCondition({ + type: FilterType.Version, + operator: VersionOperator.Equals, + value: "", + }) + } + > + Release version + + {depth < 2 && ( + + addCondition({ + type: FilterType.Comparison, + operator: ComparisonOperator.And, + conditions: [], + not: false, + }) + } + > + Filter group + + )} + {depth < 2 && ( + + addCondition({ + type: FilterType.Comparison, + operator: ComparisonOperator.And, + not: true, + conditions: [], + }) + } + > + Not group + + )} + + + +
+ +
+
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobConditionBadge.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobConditionBadge.tsx new file mode 100644 index 000000000..8d198b1c9 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobConditionBadge.tsx @@ -0,0 +1,300 @@ +import type { + CreatedAtCondition, + MetadataCondition, + VersionCondition, +} from "@ctrlplane/validators/conditions"; +import type { + ComparisonCondition, + DeploymentCondition, + EnvironmentCondition, + JobCondition, + StatusCondition, +} from "@ctrlplane/validators/jobs"; +import React from "react"; +import { noCase } from "change-case"; +import { format } from "date-fns"; +import _ from "lodash"; + +import { cn } from "@ctrlplane/ui"; +import { Badge } from "@ctrlplane/ui/badge"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@ctrlplane/ui/hover-card"; +import { + ComparisonOperator, + DateOperator, + MetadataOperator, +} from "@ctrlplane/validators/conditions"; +import { + isComparisonCondition, + isCreatedAtCondition, + isDeploymentCondition, + isEnvironmentCondition, + isMetadataCondition, + isStatusCondition, + isVersionCondition, + JobStatusReadable, +} from "@ctrlplane/validators/jobs"; + +import { api } from "~/trpc/react"; + +const operatorVerbs = { + [ComparisonOperator.And]: "and", + [ComparisonOperator.Or]: "or", + [MetadataOperator.Equals]: "is", + [MetadataOperator.Null]: ( + + is null + + ), + [MetadataOperator.Regex]: "matches", + [MetadataOperator.Like]: "contains", + [DateOperator.After]: "after", + [DateOperator.Before]: "before", + [DateOperator.AfterOrOn]: "after or on", + [DateOperator.BeforeOrOn]: "before or on", +}; + +const ConditionBadge: React.FC<{ + children: React.ReactNode; +}> = ({ children }) => ( + + {children} + +); + +const StringifiedComparisonCondition: React.FC<{ + condition: ComparisonCondition; + depth?: number; + truncate?: boolean; +}> = ({ condition, depth = 0, truncate = false }) => ( + <> + {depth !== 0 && ( + + ( + + )} + {depth === 0 || !truncate ? ( + condition.conditions.map((subCondition, index) => ( + + {index > 0 && ( + + {operatorVerbs[condition.operator]} + + )} + + + )) + ) : ( + ... + )} + + {depth !== 0 && ( + + ) + + )} + +); + +const StringifiedTabbedComparisonCondition: React.FC<{ + condition: ComparisonCondition; + depth?: number; +}> = ({ condition, depth = 0 }) => { + const [comparisonSubConditions, otherSubConditions] = _.partition( + condition.conditions, + (subCondition) => isComparisonCondition(subCondition), + ); + const conditionsOrdered = [...otherSubConditions, ...comparisonSubConditions]; + + return ( +
+ {conditionsOrdered.map((subCondition, index) => ( + + {index > 0 && ( +
+ {operatorVerbs[condition.operator]} +
+ )} + +
+ ))} +
+ ); +}; + +const StringifiedMetadataCondition: React.FC<{ + condition: MetadataCondition; +}> = ({ condition }) => ( + + {condition.key} + + {operatorVerbs[condition.operator ?? "equals"]} + + {condition.value != null && ( + {condition.value} + )} + +); + +const StringifiedCreatedAtCondition: React.FC<{ + condition: CreatedAtCondition; +}> = ({ condition }) => ( + + created + + {operatorVerbs[condition.operator]} + + + {format(condition.value, "MMM d, yyyy, h:mma")} + + +); + +const StringifiedStatusCondition: React.FC<{ + condition: StatusCondition; +}> = ({ condition }) => ( + + status + + {operatorVerbs[condition.operator]} + + + {noCase(JobStatusReadable[condition.value])} + + +); + +const StringifiedDeploymentCondition: React.FC<{ + condition: DeploymentCondition; +}> = ({ condition }) => { + const deploymentQ = api.deployment.byId.useQuery(condition.value); + const deployment = deploymentQ.data; + + return ( + + deployment + + {operatorVerbs[condition.operator]} + + {noCase(deployment?.name ?? "")} + + ); +}; + +const StringifiedEnvironmentCondition: React.FC<{ + condition: EnvironmentCondition; +}> = ({ condition }) => { + const environmentQ = api.environment.byId.useQuery(condition.value); + const environment = environmentQ.data; + + return ( + + environment + + {operatorVerbs[condition.operator]} + + {noCase(environment?.name ?? "")} + + ); +}; + +const StringifiedVersionCondition: React.FC<{ + condition: VersionCondition; +}> = ({ condition }) => ( + + version + + {operatorVerbs[condition.operator]} + + {condition.value} + +); + +const StringifiedJobCondition: React.FC<{ + condition: JobCondition; + depth?: number; + truncate?: boolean; + tabbed?: boolean; +}> = ({ condition, depth = 0, truncate = false, tabbed = false }) => { + if (isComparisonCondition(condition)) + return tabbed ? ( + + ) : ( + + ); + + if (isMetadataCondition(condition)) + return ; + + if (isCreatedAtCondition(condition)) + return ; + + if (isStatusCondition(condition)) + return ; + + if (isDeploymentCondition(condition)) + return ; + + if (isEnvironmentCondition(condition)) + return ; + + if (isVersionCondition(condition)) + return ; + + return null; +}; + +export const JobConditionBadge: React.FC<{ + condition: JobCondition; + tabbed?: boolean; +}> = ({ condition, tabbed = false }) => ( + + +
+ +
+
+ +
+ +
+
+
+); diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobConditionDialog.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobConditionDialog.tsx new file mode 100644 index 000000000..5437b47a6 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobConditionDialog.tsx @@ -0,0 +1,87 @@ +import type { JobCondition } from "@ctrlplane/validators/jobs"; +import { useState } from "react"; + +import { Button } from "@ctrlplane/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@ctrlplane/ui/dialog"; +import { MAX_DEPTH_ALLOWED } from "@ctrlplane/validators/conditions"; +import { + defaultCondition, + isValidJobCondition, +} from "@ctrlplane/validators/jobs"; + +import { JobConditionRender } from "./JobConditionRender"; + +type JobConditionDialogProps = { + condition?: JobCondition; + onChange: (condition: JobCondition | undefined) => void; + children: React.ReactNode; +}; + +export const JobConditionDialog: React.FC = ({ + condition, + onChange, + children, +}) => { + const [open, setOpen] = useState(false); + const [error, setError] = useState(null); + const [localCondition, setLocalCondition] = useState( + condition ?? defaultCondition, + ); + + return ( + + {children} + e.stopPropagation()} + > + + Edit Job Condition + + Edit the job filter, up to a depth of {MAX_DEPTH_ALLOWED + 1}. + + + + {error && {error}} + + +
+ + + +
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobConditionRender.tsx new file mode 100644 index 000000000..01c7291c6 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobConditionRender.tsx @@ -0,0 +1,100 @@ +import type { JobCondition } from "@ctrlplane/validators/jobs"; +import React from "react"; + +import { + isComparisonCondition, + isCreatedAtCondition, + isDeploymentCondition, + isEnvironmentCondition, + isMetadataCondition, + isStatusCondition, + isVersionCondition, +} from "@ctrlplane/validators/jobs"; + +import type { JobConditionRenderProps } from "./job-condition-props"; +import { DeploymentConditionRender } from "./DeploymentConditionRender"; +import { EnvironmentConditionRender } from "./EnvironmentConditionRender"; +import { JobComparisonConditionRender } from "./JobComparisonConditionRender"; +import { JobCreatedAtConditionRender } from "./JobCreatedAtConditionRender"; +import { JobMetadataConditionRender } from "./JobMetadataConditionRender"; +import { StatusConditionRender } from "./StatusConditionRender"; +import { JobReleaseVersionConditionRender } from "./VersionConditionRender"; + +/** + * The parent container should have min width of 1000px + * to render this component properly. + */ +export const JobConditionRender: React.FC< + JobConditionRenderProps +> = ({ condition, onChange, onRemove, depth = 0, className }) => { + if (isComparisonCondition(condition)) + return ( + + ); + + if (isCreatedAtCondition(condition)) + return ( + + ); + + if (isMetadataCondition(condition)) + return ( + + ); + + if (isStatusCondition(condition)) + return ( + + ); + + if (isDeploymentCondition(condition)) + return ( + + ); + + if (isEnvironmentCondition(condition)) + return ( + + ); + + if (isVersionCondition(condition)) + return ( + + ); + + return null; +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobCreatedAtConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobCreatedAtConditionRender.tsx new file mode 100644 index 000000000..a58a6c4d1 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobCreatedAtConditionRender.tsx @@ -0,0 +1,34 @@ +import type { + CreatedAtCondition, + DateOperatorType, +} from "@ctrlplane/validators/conditions"; +import type { DateValue } from "@internationalized/date"; + +import type { JobConditionRenderProps } from "./job-condition-props"; +import { DateConditionRender } from "../filter/DateConditionRender"; + +export const JobCreatedAtConditionRender: React.FC< + JobConditionRenderProps +> = ({ condition, onChange, className }) => { + const setDate = (t: DateValue) => + onChange({ + ...condition, + value: t + .toDate(Intl.DateTimeFormat().resolvedOptions().timeZone) + .toISOString(), + }); + + const setOperator = (operator: DateOperatorType) => + onChange({ ...condition, operator }); + + return ( + + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobMetadataConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobMetadataConditionRender.tsx new file mode 100644 index 000000000..5f63cb0b7 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobMetadataConditionRender.tsx @@ -0,0 +1,27 @@ +import type { MetadataCondition } from "@ctrlplane/validators/conditions"; +import { useParams } from "next/navigation"; + +import type { JobConditionRenderProps } from "./job-condition-props"; +import { api } from "~/trpc/react"; +import { MetadataConditionRender } from "../filter/MetadataConditionRender"; + +export const JobMetadataConditionRender: React.FC< + JobConditionRenderProps +> = ({ condition, onChange, className }) => { + const { versionId } = useParams<{ versionId?: string }>(); + + const metadataKeysQ = api.job.metadataKey.byReleaseId.useQuery( + versionId ?? "", + { enabled: versionId != null }, + ); + const metadataKeys = metadataKeysQ.data ?? []; + + return ( + + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/StatusConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/StatusConditionRender.tsx new file mode 100644 index 000000000..03d4faaf5 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/StatusConditionRender.tsx @@ -0,0 +1,37 @@ +import type { + JobStatusType, + StatusCondition, +} from "@ctrlplane/validators/jobs"; +import type React from "react"; + +import { JobStatus, JobStatusReadable } from "@ctrlplane/validators/jobs"; + +import type { JobConditionRenderProps } from "./job-condition-props"; +import { ChoiceConditionRender } from "../filter/ChoiceConditionRender"; + +export const StatusConditionRender: React.FC< + JobConditionRenderProps +> = ({ condition, onChange, className }) => { + const options = Object.entries(JobStatus).map(([key, value]) => ({ + key, + value, + display: JobStatusReadable[value], + })); + + const setStatus = (status: string) => + onChange({ ...condition, value: status as JobStatusType }); + + const selectedStatus = options.find( + (option) => option.value === condition.value, + ); + + return ( + + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/VersionConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/VersionConditionRender.tsx new file mode 100644 index 000000000..4a6a5595a --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/VersionConditionRender.tsx @@ -0,0 +1,26 @@ +import type { + VersionCondition, + VersionOperatorType, +} from "@ctrlplane/validators/conditions"; + +import type { JobConditionRenderProps } from "./job-condition-props"; +import { VersionConditionRender } from "../filter/VersionConditionRender"; + +export const JobReleaseVersionConditionRender: React.FC< + JobConditionRenderProps +> = ({ condition, onChange, className }) => { + const setOperator = (operator: VersionOperatorType) => + onChange({ ...condition, operator }); + const setValue = (value: string) => onChange({ ...condition, value }); + + return ( + + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/job-condition-props.ts b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/job-condition-props.ts new file mode 100644 index 000000000..14de763c1 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/job-condition-props.ts @@ -0,0 +1,9 @@ +import type { JobCondition } from "@ctrlplane/validators/jobs"; + +export type JobConditionRenderProps = { + condition: T; + onChange: (condition: T) => void; + onRemove?: () => void; + depth?: number; + className?: string; +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/useJobFilter.ts b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/useJobFilter.ts new file mode 100644 index 000000000..3326eb988 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/useJobFilter.ts @@ -0,0 +1,40 @@ +import type { JobCondition } from "@ctrlplane/validators/jobs"; +import { useCallback, useMemo } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import LZString from "lz-string"; + +export const useJobFilter = () => { + const urlParams = useSearchParams(); + const router = useRouter(); + + const filter = useMemo(() => { + const filterJson = urlParams.get("job-filter"); + if (filterJson == null) return undefined; + try { + return JSON.parse(LZString.decompressFromEncodedURIComponent(filterJson)); + } catch { + return undefined; + } + }, [urlParams]); + + const setFilter = useCallback( + (filter: JobCondition | undefined) => { + if (filter == null) { + const query = new URLSearchParams(window.location.search); + query.delete("job-filter"); + router.replace(`?${query.toString()}`); + return; + } + + const filterJson = LZString.compressToEncodedURIComponent( + JSON.stringify(filter), + ); + const query = new URLSearchParams(window.location.search); + query.set("job-filter", filterJson); + router.replace(`?${query.toString()}`); + }, + [router], + ); + + return { filter, setFilter }; +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ComparisonConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ComparisonConditionRender.tsx index 9b32fca50..1925ebbca 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ComparisonConditionRender.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ComparisonConditionRender.tsx @@ -30,6 +30,7 @@ import { SelectTrigger, SelectValue, } from "@ctrlplane/ui/select"; +import { DateOperator } from "@ctrlplane/validators/conditions"; import { doesConvertingToComparisonRespectMaxDepth, isComparisonCondition, @@ -277,7 +278,7 @@ export const ComparisonConditionRender: React.FC< onClick={() => addCondition({ type: ReleaseFilterType.CreatedAt, - operator: ReleaseOperator.Before, + operator: DateOperator.Before, value: new Date().toISOString(), }) } diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseConditionBadge.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseConditionBadge.tsx index 5f3481e30..6d2d8667f 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseConditionBadge.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseConditionBadge.tsx @@ -1,11 +1,11 @@ import type { CreatedAtCondition, MetadataCondition, + VersionCondition, } from "@ctrlplane/validators/conditions"; import type { ComparisonCondition, ReleaseCondition, - VersionCondition, } from "@ctrlplane/validators/releases"; import React from "react"; import { format } from "date-fns"; @@ -18,6 +18,7 @@ import { HoverCardContent, HoverCardTrigger, } from "@ctrlplane/ui/hover-card"; +import { DateOperator } from "@ctrlplane/validators/conditions"; import { isComparisonCondition, isCreatedAtCondition, @@ -37,10 +38,10 @@ const operatorVerbs = { ), [ReleaseOperator.Regex]: "matches", [ReleaseOperator.Like]: "contains", - [ReleaseOperator.After]: "after", - [ReleaseOperator.Before]: "before", - [ReleaseOperator.AfterOrOn]: "after or on", - [ReleaseOperator.BeforeOrOn]: "before or on", + [DateOperator.After]: "after", + [DateOperator.Before]: "before", + [DateOperator.AfterOrOn]: "after or on", + [DateOperator.BeforeOrOn]: "before or on", }; const ConditionBadge: React.FC<{ diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseConditionRender.tsx index b273d3577..5c17dba8a 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseConditionRender.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseConditionRender.tsx @@ -10,9 +10,9 @@ import { import type { ReleaseConditionRenderProps } from "./release-condition-props"; import { ComparisonConditionRender } from "./ComparisonConditionRender"; -import { CreatedAtConditionRender } from "./CreatedAtConditionRender"; +import { CreatedAtConditionRender } from "./ReleaseCreatedAtConditionRender"; import { ReleaseMetadataConditionRender } from "./ReleaseMetadataConditionRender"; -import { VersionConditionRender } from "./VersionConditionRender"; +import { ReleaseVersionConditionRender } from "./VersionConditionRender"; /** * The parent container should have min width of 1000px @@ -56,7 +56,7 @@ export const ReleaseConditionRender: React.FC< if (isVersionCondition(condition)) return ( - onChange({ ...condition, operator }); + const setOperator = (operator: DateOperatorType) => + onChange({ ...condition, operator }); return ( > = ({ condition, onChange, className }) => { - const setOperator = ( - operator: - | ReleaseOperator.Equals - | ReleaseOperator.Like - | ReleaseOperator.Regex, - ) => onChange({ ...condition, operator }); + const setOperator = (operator: VersionOperatorType) => + onChange({ ...condition, operator }); const setValue = (value: string) => onChange({ ...condition, value }); return ( -
-
-
- Version -
-
- -
-
- setValue(e.target.value)} - className="w-full cursor-pointer rounded-l-none" - /> -
-
-
+ ); }; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/target-condition/KindConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/target-condition/KindConditionRender.tsx index 9ee735a5b..bf6e4d0fc 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/target-condition/KindConditionRender.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/target-condition/KindConditionRender.tsx @@ -22,6 +22,8 @@ export const KindConditionRender: React.FC< display: kind, })); + const loading = workspace.isLoading || kinds.isLoading; + return ( ); }; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/target-condition/ProviderConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/target-condition/ProviderConditionRender.tsx index d010b0e46..5401e6a6d 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/target-condition/ProviderConditionRender.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/target-condition/ProviderConditionRender.tsx @@ -28,6 +28,8 @@ export const ProviderConditionRender: React.FC< display: provider.name, })); + const loading = workspace.isLoading || providers.isLoading; + return ( ); }; diff --git a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/[versionId]/FlowNode.tsx b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/[versionId]/FlowNode.tsx index 87696247d..f4118308f 100644 --- a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/[versionId]/FlowNode.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/[versionId]/FlowNode.tsx @@ -17,7 +17,7 @@ type EnvironmentNodeProps = NodeProps< export const EnvironmentNode: React.FC = (node) => { const { data } = node; const releaseJobTriggers = api.job.config.byReleaseId.useQuery( - data.release.id, + { releaseId: data.release.id }, { refetchInterval: 10_000 }, ); const environmentJobs = releaseJobTriggers.data?.filter( diff --git a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/[versionId]/FlowPolicyNode.tsx b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/[versionId]/FlowPolicyNode.tsx index 9f37e9753..a496c5a4c 100644 --- a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/[versionId]/FlowPolicyNode.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/[versionId]/FlowPolicyNode.tsx @@ -164,9 +164,10 @@ const MinSucessCheck: React.FC = ({ release, policyDeployments, }) => { - const allJobs = api.job.config.byReleaseId.useQuery(release.id, { - refetchInterval: 10_000, - }); + const allJobs = api.job.config.byReleaseId.useQuery( + { releaseId: release.id }, + { refetchInterval: 10_000 }, + ); const envIds = policyDeployments.map((p) => p.environmentId); const jobs = allJobs.data?.filter((j) => envIds.includes(j.environmentId)); diff --git a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/[versionId]/JobsTable.tsx b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/[versionId]/JobsTable.tsx index 48af80185..c5d99ddf5 100644 --- a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/[versionId]/JobsTable.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/[versionId]/JobsTable.tsx @@ -14,9 +14,10 @@ import { import { api } from "~/trpc/react"; export const JobsTable: React.FC<{ releaseId: string }> = ({ releaseId }) => { - const releaseJobTriggers = api.job.config.byReleaseId.useQuery(releaseId, { - refetchInterval: 10_000, - }); + const releaseJobTriggers = api.job.config.byReleaseId.useQuery( + { releaseId }, + { refetchInterval: 10_000 }, + ); return ( diff --git a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/[versionId]/TargetReleaseTable.tsx b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/[versionId]/TargetReleaseTable.tsx index 48729a0f0..39db18ceb 100644 --- a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/[versionId]/TargetReleaseTable.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/[versionId]/TargetReleaseTable.tsx @@ -3,7 +3,12 @@ import React, { Fragment } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { IconDots, IconExternalLink, IconLoader2 } from "@tabler/icons-react"; +import { + IconDots, + IconExternalLink, + IconFilter, + IconLoader2, +} from "@tabler/icons-react"; import { capitalCase } from "change-case"; import _ from "lodash"; @@ -12,6 +17,9 @@ import { Button, buttonVariants } from "@ctrlplane/ui/button"; import { Table, TableBody, TableCell, TableRow } from "@ctrlplane/ui/table"; import { ReservedMetadataKey } from "@ctrlplane/validators/conditions"; +import { JobConditionBadge } from "~/app/[workspaceSlug]/_components/job-condition/JobConditionBadge"; +import { JobConditionDialog } from "~/app/[workspaceSlug]/_components/job-condition/JobConditionDialog"; +import { useJobFilter } from "~/app/[workspaceSlug]/_components/job-condition/useJobFilter"; import { useJobDrawer } from "~/app/[workspaceSlug]/_components/job-drawer/useJobDrawer"; import { JobTableStatusIcon } from "~/app/[workspaceSlug]/_components/JobTableStatusIcon"; import { api } from "~/trpc/react"; @@ -26,11 +34,12 @@ export const TargetReleaseTable: React.FC = ({ release, deploymentName, }) => { + const { filter, setFilter } = useJobFilter(); const pathname = usePathname(); const { setJobId } = useJobDrawer(); const releaseJobTriggerQuery = api.job.config.byReleaseId.useQuery( - release.id, - { refetchInterval: 5_000 }, + { releaseId: release.id, filter }, + { refetchInterval: 5_000, placeholderData: (prev) => prev }, ); if (releaseJobTriggerQuery.isLoading) return ( @@ -40,118 +49,129 @@ export const TargetReleaseTable: React.FC = ({ ); return ( -
- - {_.chain(releaseJobTriggerQuery.data) - .groupBy((r) => r.environmentId) - .entries() - .map(([envId, jobs]) => { - return ( - - - - {jobs[0]?.environment != null && ( -
-
- {jobs[0].environment.name} + <> + +
+ + {filter != null && } +
+
+ +
+ + {_.chain(releaseJobTriggerQuery.data) + .groupBy((r) => r.environmentId) + .entries() + .map(([envId, jobs]) => { + return ( + + + + {jobs[0]?.environment != null && ( +
+
+ {jobs[0].environment.name} +
- - )} -
-
- {jobs.map((job, idx) => { - const linksMetadata = job.job.metadata.find( - (m) => m.key === String(ReservedMetadataKey.Links), - )?.value; + )} + + + {jobs.map((job, idx) => { + const linksMetadata = job.job.metadata.find( + (m) => m.key === String(ReservedMetadataKey.Links), + )?.value; - const links = - linksMetadata != null - ? (JSON.parse(linksMetadata) as Record) - : null; + const links = + linksMetadata != null + ? (JSON.parse(linksMetadata) as Record) + : null; - return ( - setJobId(job.job.id)} - > - - - {job.target.name} - - - -
- - {capitalCase(job.job.status)} -
-
- {job.type} - - {job.job.externalId != null ? ( - - {job.job.externalId} - - ) : ( - - No external ID - + return ( + - - {links != null && ( + onClick={() => setJobId(job.job.id)} + > + + + {job.target.name} + + +
- {Object.entries(links).map(([label, url]) => ( - - - {label} - - ))} + + {capitalCase(job.job.status)}
- )} -
- e.stopPropagation()}> -
- - - -
-
-
- ); - })} -
- ); - }) - .value()} -
-
+ + {job.type} + + {job.job.externalId != null ? ( + + {job.job.externalId} + + ) : ( + + No external ID + + )} + + + {links != null && ( +
+ {Object.entries(links).map(([label, url]) => ( + + + {label} + + ))} +
+ )} +
+ e.stopPropagation()}> +
+ + + +
+
+ + ); + })} + + ); + }) + .value()} + + + ); }; diff --git a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/[versionId]/page.tsx b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/[versionId]/page.tsx index 1a9364e6f..53956598a 100644 --- a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/[versionId]/page.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/[versionId]/page.tsx @@ -1,8 +1,6 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; -import { IconFilter } from "@tabler/icons-react"; -import { Button } from "@ctrlplane/ui/button"; import { ScrollArea } from "@ctrlplane/ui/scroll-area"; import { ReactFlowProvider } from "~/app/[workspaceSlug]/_components/reactflow/ReactFlowProvider"; @@ -82,12 +80,6 @@ export default async function ReleasePage({
)} -
- -
- + canUser + .perform(Permission.DeploymentGet) + .on({ type: "deployment", id: input }), + }) + .query(({ ctx, input }) => + ctx.db + .select() + .from(deployment) + .where(eq(deployment.id, input)) + .then(takeFirst), + ), + bySlug: protectedProcedure .input( z.object({ diff --git a/packages/api/src/router/environment.ts b/packages/api/src/router/environment.ts index b94cee5cd..33b4d8bfa 100644 --- a/packages/api/src/router/environment.ts +++ b/packages/api/src/router/environment.ts @@ -482,6 +482,26 @@ export const environmentRouter = createTRPCRouter({ ); }), + byWorkspaceId: protectedProcedure + .meta({ + authorizationCheck: ({ canUser, input }) => + canUser + .perform(Permission.SystemGet) + .on({ type: "workspace", id: input }), + }) + .input(z.string().uuid()) + .query(({ ctx, input }) => + ctx.db + .select() + .from(environment) + .innerJoin(system, eq(environment.systemId, system.id)) + .where(eq(system.workspaceId, input)) + .orderBy(environment.name) + .then((envs) => + envs.map((e) => ({ ...e.environment, system: e.system })), + ), + ), + create: protectedProcedure .meta({ authorizationCheck: ({ canUser, input }) => diff --git a/packages/api/src/router/job.ts b/packages/api/src/router/job.ts index 972c4fa68..439d4ea38 100644 --- a/packages/api/src/router/job.ts +++ b/packages/api/src/router/job.ts @@ -21,6 +21,7 @@ import { z } from "zod"; import { and, asc, + countDistinct, desc, eq, inArray, @@ -37,6 +38,7 @@ import { environmentPolicyReleaseWindow, job, jobAgent, + jobMatchesCondition, jobMetadata, jobVariable, release, @@ -56,7 +58,7 @@ import { onJobCompletion, } from "@ctrlplane/job-dispatch"; import { Permission } from "@ctrlplane/validators/auth"; -import { JobStatus } from "@ctrlplane/validators/jobs"; +import { jobCondition, JobStatus } from "@ctrlplane/validators/jobs"; import { createTRPCRouter, protectedProcedure } from "../trpc"; @@ -96,9 +98,17 @@ const processReleaseJobTriggerWithAdditionalDataRows = ( causedBy: v[0]!.user, job: { ...v[0]!.job, - metadata: v.map((t) => t.job_metadata).filter(isPresent), + metadata: _.chain(v) + .filter((v) => isPresent(v.job_metadata)) + .groupBy((v) => v.job_metadata!.id) + .map((v) => v[0]!.job_metadata!) + .value(), status: v[0]!.job.status as JobStatus, - variables: v.map((t) => t.job_variable).filter(isPresent), + variables: _.chain(v) + .filter((v) => isPresent(v.job_variable)) + .groupBy((v) => v.job_variable!.id) + .map((v) => v[0]!.job_variable!) + .value(), }, jobAgent: v[0]!.job_agent, target: v[0]!.target, @@ -133,21 +143,32 @@ const processReleaseJobTriggerWithAdditionalDataRows = ( const releaseJobTriggerRouter = createTRPCRouter({ byWorkspaceId: createTRPCRouter({ list: protectedProcedure - .input(z.string().uuid()) + .input( + z.object({ + workspaceId: z.string().uuid(), + filter: jobCondition.optional(), + limit: z.number().int().nonnegative().max(1000).default(500), + offset: z.number().int().nonnegative().default(0), + }), + ) .meta({ authorizationCheck: ({ canUser, input }) => canUser - .perform(Permission.SystemList) - .on({ type: "workspace", id: input }), + .perform(Permission.JobList) + .on({ type: "workspace", id: input.workspaceId }), }) - .query(({ ctx, input }) => - releaseJobTriggerQuery(ctx.db) + .query(async ({ ctx, input }) => { + const items = await releaseJobTriggerQuery(ctx.db) .leftJoin(system, eq(system.id, deployment.systemId)) .where( - and(eq(system.workspaceId, input), isNull(environment.deletedAt)), + and( + eq(system.workspaceId, input.workspaceId), + jobMatchesCondition(ctx.db, input.filter), + ), ) .orderBy(asc(releaseJobTrigger.createdAt)) - .limit(1_000) + .limit(input.limit) + .offset(input.offset) .then((data) => data.map((t) => ({ ...t.release_job_trigger, @@ -157,8 +178,32 @@ const releaseJobTriggerRouter = createTRPCRouter({ release: { ...t.release, deployment: t.deployment }, environment: t.environment, })), - ), - ), + ); + + const total = await ctx.db + .select({ + count: countDistinct(releaseJobTrigger.id), + }) + .from(releaseJobTrigger) + .innerJoin(job, eq(releaseJobTrigger.jobId, job.id)) + .innerJoin(release, eq(releaseJobTrigger.releaseId, release.id)) + .innerJoin(deployment, eq(release.deploymentId, deployment.id)) + .innerJoin( + environment, + eq(releaseJobTrigger.environmentId, environment.id), + ) + .innerJoin(system, eq(environment.systemId, system.id)) + .where( + and( + eq(system.workspaceId, input.workspaceId), + jobMatchesCondition(ctx.db, input.filter), + ), + ) + .then(takeFirst) + .then((t) => t.count); + + return { items, total }; + }), dailyCount: protectedProcedure .input( z.object({ @@ -282,9 +327,14 @@ const releaseJobTriggerRouter = createTRPCRouter({ authorizationCheck: ({ canUser, input }) => canUser .perform(Permission.DeploymentGet) - .on({ type: "release", id: input }), + .on({ type: "release", id: input.releaseId }), }) - .input(z.string().uuid()) + .input( + z.object({ + releaseId: z.string().uuid(), + filter: jobCondition.optional(), + }), + ) .query(({ ctx, input }) => releaseJobTriggerQuery(ctx.db) .leftJoin(jobMetadata, eq(jobMetadata.jobId, job.id)) @@ -296,7 +346,12 @@ const releaseJobTriggerRouter = createTRPCRouter({ environmentPolicyReleaseWindow, eq(environmentPolicyReleaseWindow.policyId, environmentPolicy.id), ) - .where(and(eq(release.id, input), isNull(environment.deletedAt))) + .where( + and( + eq(release.id, input.releaseId), + jobMatchesCondition(ctx.db, input.filter), + ), + ) .orderBy(desc(releaseJobTrigger.createdAt)) .then(processReleaseJobTriggerWithAdditionalDataRows), ), @@ -552,6 +607,30 @@ const jobTriggerRouter = createTRPCRouter({ }), }); +const metadataKeysRouter = createTRPCRouter({ + byReleaseId: protectedProcedure + .meta({ + authorizationCheck: ({ canUser, input }) => + canUser + .perform(Permission.ReleaseGet) + .on({ type: "release", id: input }), + }) + .input(z.string().uuid()) + .query(({ ctx, input }) => + ctx.db + .selectDistinct({ key: jobMetadata.key }) + .from(release) + .innerJoin( + releaseJobTrigger, + eq(releaseJobTrigger.releaseId, release.id), + ) + .innerJoin(job, eq(releaseJobTrigger.jobId, job.id)) + .innerJoin(jobMetadata, eq(jobMetadata.jobId, job.id)) + .where(eq(release.id, input)) + .then((r) => r.map((row) => row.key)), + ), +}); + export const jobRouter = createTRPCRouter({ byTargetId: protectedProcedure .meta({ @@ -605,4 +684,5 @@ export const jobRouter = createTRPCRouter({ config: releaseJobTriggerRouter, agent: jobAgentRouter, trigger: jobTriggerRouter, + metadataKey: metadataKeysRouter, }); diff --git a/packages/db/src/schema/job.ts b/packages/db/src/schema/job.ts index 618812cc6..879774c4e 100644 --- a/packages/db/src/schema/job.ts +++ b/packages/db/src/schema/job.ts @@ -1,4 +1,24 @@ -import type { InferInsertModel, InferSelectModel } from "drizzle-orm"; +import type { + CreatedAtCondition, + MetadataCondition, + VersionCondition, +} from "@ctrlplane/validators/conditions"; +import type { JobCondition } from "@ctrlplane/validators/jobs"; +import type { InferInsertModel, InferSelectModel, SQL } from "drizzle-orm"; +import { + and, + eq, + exists, + gt, + gte, + like, + lt, + lte, + not, + notExists, + or, + sql, +} from "drizzle-orm"; import { boolean, json, @@ -11,7 +31,19 @@ import { } from "drizzle-orm/pg-core"; import { createInsertSchema } from "drizzle-zod"; +import { + ComparisonOperator, + DateOperator, + FilterType, + VersionOperator, +} from "@ctrlplane/validators/conditions"; +import { JobFilterType } from "@ctrlplane/validators/jobs"; + +import type { Tx } from "../common.js"; +import { deployment } from "./deployment.js"; +import { environment } from "./environment.js"; import { jobAgent } from "./job-agent.js"; +import { release } from "./release.js"; // if adding a new status, update the validators package @ctrlplane/validators/src/jobs/index.ts export const jobStatus = pgEnum("job_status", [ @@ -103,3 +135,99 @@ export const createJobVariable = createInsertSchema(jobVariable).omit({ id: true, }); export const updateJobVariable = createJobVariable.partial(); + +const buildMetadataCondition = (tx: Tx, cond: MetadataCondition): SQL => { + if (cond.operator === "null") + return notExists( + tx + .select() + .from(jobMetadata) + .where( + and(eq(jobMetadata.jobId, job.id), eq(jobMetadata.key, cond.key)), + ), + ); + + if (cond.operator === "regex") + return exists( + tx + .select() + .from(jobMetadata) + .where( + and( + eq(jobMetadata.jobId, job.id), + eq(jobMetadata.key, cond.key), + sql`${jobMetadata.value} ~ ${cond.value}`, + ), + ), + ); + + if (cond.operator === "like") + return exists( + tx + .select() + .from(jobMetadata) + .where( + and( + eq(jobMetadata.jobId, job.id), + eq(jobMetadata.key, cond.key), + like(jobMetadata.value, cond.value), + ), + ), + ); + + return exists( + tx + .select() + .from(jobMetadata) + .where( + and( + eq(jobMetadata.jobId, job.id), + eq(jobMetadata.key, cond.key), + eq(jobMetadata.value, cond.value), + ), + ), + ); +}; + +const buildCreatedAtCondition = (cond: CreatedAtCondition): SQL => { + const date = new Date(cond.value); + if (cond.operator === DateOperator.Before) return lt(job.createdAt, date); + if (cond.operator === DateOperator.After) return gt(job.createdAt, date); + if (cond.operator === DateOperator.BeforeOrOn) + return lte(job.createdAt, date); + return gte(job.createdAt, date); +}; + +const buildVersionCondition = (cond: VersionCondition): SQL => { + if (cond.operator === VersionOperator.Like) + return like(release.version, cond.value); + if (cond.operator === VersionOperator.Regex) + return sql`${release.version} ~ ${cond.value}`; + return eq(release.version, cond.value); +}; + +const buildCondition = (tx: Tx, cond: JobCondition): SQL => { + if (cond.type === FilterType.Metadata) + return buildMetadataCondition(tx, cond); + if (cond.type === FilterType.CreatedAt) return buildCreatedAtCondition(cond); + if (cond.type === JobFilterType.Status) return eq(job.status, cond.value); + if (cond.type === JobFilterType.Deployment) + return eq(deployment.id, cond.value); + if (cond.type === JobFilterType.Environment) + return eq(environment.id, cond.value); + if (cond.type === FilterType.Version) return buildVersionCondition(cond); + + const subCon = cond.conditions.map((c) => buildCondition(tx, c)); + const con = + cond.operator === ComparisonOperator.And ? and(...subCon)! : or(...subCon)!; + return cond.not ? not(con) : con; +}; + +export function jobMatchesCondition( + tx: Tx, + condition?: JobCondition, +): SQL | undefined { + return condition == null || Object.keys(condition).length === 0 + ? undefined + : buildCondition(tx, condition); +} diff --git a/packages/db/src/schema/release.ts b/packages/db/src/schema/release.ts index 09b8996a8..8e8e2ea7e 100644 --- a/packages/db/src/schema/release.ts +++ b/packages/db/src/schema/release.ts @@ -1,11 +1,9 @@ import type { CreatedAtCondition, MetadataCondition, -} from "@ctrlplane/validators/conditions"; -import type { - ReleaseCondition, VersionCondition, -} from "@ctrlplane/validators/releases"; +} from "@ctrlplane/validators/conditions"; +import type { ReleaseCondition } from "@ctrlplane/validators/releases"; import type { InferInsertModel, InferSelectModel, SQL } from "drizzle-orm"; import { and, @@ -33,6 +31,10 @@ import { import { createInsertSchema } from "drizzle-zod"; import { z } from "zod"; +import { + DateOperator, + VersionOperator, +} from "@ctrlplane/validators/conditions"; import { releaseCondition, ReleaseFilterType, @@ -219,19 +221,17 @@ const buildMetadataCondition = (tx: Tx, cond: MetadataCondition): SQL => { const buildCreatedAtCondition = (cond: CreatedAtCondition): SQL => { const date = new Date(cond.value); - if (cond.operator === ReleaseOperator.Before) - return lt(release.createdAt, date); - if (cond.operator === ReleaseOperator.After) - return gt(release.createdAt, date); - if (cond.operator === ReleaseOperator.BeforeOrOn) + if (cond.operator === DateOperator.Before) return lt(release.createdAt, date); + if (cond.operator === DateOperator.After) return gt(release.createdAt, date); + if (cond.operator === DateOperator.BeforeOrOn) return lte(release.createdAt, date); return gte(release.createdAt, date); }; const buildVersionCondition = (cond: VersionCondition): SQL => { - if (cond.operator === ReleaseOperator.Equals) + if (cond.operator === VersionOperator.Equals) return eq(release.version, cond.value); - if (cond.operator === ReleaseOperator.Like) + if (cond.operator === VersionOperator.Like) return like(release.version, cond.value); return sql`${release.version} ~ ${cond.value}`; }; diff --git a/packages/validators/src/auth/index.ts b/packages/validators/src/auth/index.ts index 781d353bc..7b2fd8ead 100644 --- a/packages/validators/src/auth/index.ts +++ b/packages/validators/src/auth/index.ts @@ -25,6 +25,7 @@ export enum Permission { JobUpdate = "job.update", JobGet = "job.get", + JobList = "job.list", JobAgentList = "jobAgent.list", JobAgentCreate = "jobAgent.create", diff --git a/packages/validators/src/conditions/date-condition.ts b/packages/validators/src/conditions/date-condition.ts index 1c0e09c4d..302ece1fb 100644 --- a/packages/validators/src/conditions/date-condition.ts +++ b/packages/validators/src/conditions/date-condition.ts @@ -20,6 +20,12 @@ export enum DateOperator { AfterOrOn = "after-or-on", } +export type DateOperatorType = + | DateOperator.Before + | DateOperator.After + | DateOperator.BeforeOrOn + | DateOperator.AfterOrOn; + export const createdAtCondition = z.object({ type: createdAt, operator, diff --git a/packages/validators/src/conditions/index.ts b/packages/validators/src/conditions/index.ts index ef3e3fb5a..54d00f183 100644 --- a/packages/validators/src/conditions/index.ts +++ b/packages/validators/src/conditions/index.ts @@ -1,2 +1,24 @@ export * from "./metadata-condition.js"; export * from "./date-condition.js"; +export * from "./version-condition.js"; + +export enum ColumnOperator { + Equals = "equals", + Like = "like", + Regex = "regex", + Null = "null", +} + +export enum ComparisonOperator { + And = "and", + Or = "or", +} + +export enum FilterType { + Metadata = "metadata", + CreatedAt = "created-at", + Comparison = "comparison", + Version = "version", +} + +export const MAX_DEPTH_ALLOWED = 2; // 0 indexed diff --git a/packages/validators/src/releases/conditions/version-condition.ts b/packages/validators/src/conditions/version-condition.ts similarity index 64% rename from packages/validators/src/releases/conditions/version-condition.ts rename to packages/validators/src/conditions/version-condition.ts index 9884f720f..c51b95e79 100644 --- a/packages/validators/src/releases/conditions/version-condition.ts +++ b/packages/validators/src/conditions/version-condition.ts @@ -7,3 +7,11 @@ export const versionCondition = z.object({ }); export type VersionCondition = z.infer; + +export enum VersionOperator { + Like = "like", + Regex = "regex", + Equals = "equals", +} + +export type VersionOperatorType = "like" | "regex" | "equals"; diff --git a/packages/validators/src/jobs/conditions/comparison-condition.ts b/packages/validators/src/jobs/conditions/comparison-condition.ts new file mode 100644 index 000000000..8a7444f5e --- /dev/null +++ b/packages/validators/src/jobs/conditions/comparison-condition.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; + +import type { + CreatedAtCondition, + MetadataCondition, +} from "../../conditions/index.js"; +import type { VersionCondition } from "../../conditions/version-condition.js"; +import type { DeploymentCondition } from "./deployment-condition.js"; +import type { EnvironmentCondition } from "./environment-condition.js"; +import type { StatusCondition } from "./status-condition.js"; +import { + createdAtCondition, + metadataCondition, +} from "../../conditions/index.js"; +import { versionCondition } from "../../conditions/version-condition.js"; +import { deploymentCondition } from "./deployment-condition.js"; +import { environmentCondition } from "./environment-condition.js"; +import { statusCondition } from "./status-condition.js"; + +export const comparisonCondition: z.ZodType = z.lazy(() => + z.object({ + type: z.literal("comparison"), + operator: z.literal("and").or(z.literal("or")), + not: z.boolean().optional().default(false), + conditions: z.array( + z.union([ + metadataCondition, + comparisonCondition, + createdAtCondition, + statusCondition, + deploymentCondition, + environmentCondition, + versionCondition, + ]), + ), + }), +); + +export type ComparisonCondition = { + type: "comparison"; + operator: "and" | "or"; + not?: boolean; + conditions: Array< + | ComparisonCondition + | MetadataCondition + | CreatedAtCondition + | StatusCondition + | DeploymentCondition + | EnvironmentCondition + | VersionCondition + >; +}; diff --git a/packages/validators/src/jobs/conditions/deployment-condition.ts b/packages/validators/src/jobs/conditions/deployment-condition.ts new file mode 100644 index 000000000..6c231d7e9 --- /dev/null +++ b/packages/validators/src/jobs/conditions/deployment-condition.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const deploymentCondition = z.object({ + type: z.literal("deployment"), + operator: z.literal("equals"), + value: z.string().uuid(), +}); + +export type DeploymentCondition = z.infer; diff --git a/packages/validators/src/jobs/conditions/environment-condition.ts b/packages/validators/src/jobs/conditions/environment-condition.ts new file mode 100644 index 000000000..96aedad46 --- /dev/null +++ b/packages/validators/src/jobs/conditions/environment-condition.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const environmentCondition = z.object({ + type: z.literal("environment"), + operator: z.literal("equals"), + value: z.string().uuid(), +}); + +export type EnvironmentCondition = z.infer; diff --git a/packages/validators/src/jobs/conditions/index.ts b/packages/validators/src/jobs/conditions/index.ts new file mode 100644 index 000000000..9df750120 --- /dev/null +++ b/packages/validators/src/jobs/conditions/index.ts @@ -0,0 +1,5 @@ +export * from "./job-condition.js"; +export * from "./comparison-condition.js"; +export * from "./status-condition.js"; +export * from "./deployment-condition.js"; +export * from "./environment-condition.js"; diff --git a/packages/validators/src/jobs/conditions/job-condition.ts b/packages/validators/src/jobs/conditions/job-condition.ts new file mode 100644 index 000000000..2437a4796 --- /dev/null +++ b/packages/validators/src/jobs/conditions/job-condition.ts @@ -0,0 +1,116 @@ +import { z } from "zod"; + +import type { + CreatedAtCondition, + MetadataCondition, +} from "../../conditions/index.js"; +import type { VersionCondition } from "../../conditions/version-condition.js"; +import type { ComparisonCondition } from "./comparison-condition.js"; +import type { DeploymentCondition } from "./deployment-condition.js"; +import type { EnvironmentCondition } from "./environment-condition.js"; +import type { StatusCondition } from "./status-condition.js"; +import { + ComparisonOperator, + createdAtCondition, + FilterType, + MAX_DEPTH_ALLOWED, + metadataCondition, + MetadataOperator, +} from "../../conditions/index.js"; +import { versionCondition } from "../../conditions/version-condition.js"; +import { comparisonCondition } from "./comparison-condition.js"; +import { deploymentCondition } from "./deployment-condition.js"; +import { environmentCondition } from "./environment-condition.js"; +import { statusCondition } from "./status-condition.js"; + +export type JobCondition = + | ComparisonCondition + | MetadataCondition + | CreatedAtCondition + | StatusCondition + | DeploymentCondition + | EnvironmentCondition + | VersionCondition; + +export const jobCondition = z.union([ + comparisonCondition, + metadataCondition, + createdAtCondition, + statusCondition, + deploymentCondition, + environmentCondition, + versionCondition, +]); + +export const defaultCondition: JobCondition = { + type: FilterType.Comparison, + operator: ComparisonOperator.And, + not: false, + conditions: [], +}; + +export enum JobFilterType { + Status = "status", + Deployment = "deployment", + Environment = "environment", +} + +export const isEmptyCondition = (condition: JobCondition): boolean => + condition.type === FilterType.Comparison && condition.conditions.length === 0; + +export const isComparisonCondition = ( + condition: JobCondition, +): condition is ComparisonCondition => condition.type === FilterType.Comparison; + +export const isMetadataCondition = ( + condition: JobCondition, +): condition is MetadataCondition => condition.type === FilterType.Metadata; + +export const isCreatedAtCondition = ( + condition: JobCondition, +): condition is CreatedAtCondition => condition.type === FilterType.CreatedAt; + +export const isStatusCondition = ( + condition: JobCondition, +): condition is StatusCondition => condition.type === JobFilterType.Status; + +export const isEnvironmentCondition = ( + condition: JobCondition, +): condition is EnvironmentCondition => + condition.type === JobFilterType.Environment; + +export const isDeploymentCondition = ( + condition: JobCondition, +): condition is DeploymentCondition => + condition.type === JobFilterType.Deployment; + +export const isVersionCondition = ( + condition: JobCondition, +): condition is VersionCondition => condition.type === FilterType.Version; + +// Check if converting to a comparison condition will exceed the max depth +// including any nested conditions +export const doesConvertingToComparisonRespectMaxDepth = ( + depth: number, + condition: JobCondition, +): boolean => { + if (depth > MAX_DEPTH_ALLOWED) return false; + if (isComparisonCondition(condition)) { + if (depth === MAX_DEPTH_ALLOWED) return false; + return condition.conditions.every((c) => + doesConvertingToComparisonRespectMaxDepth(depth + 1, c), + ); + } + return true; +}; + +export const isValidJobCondition = (condition: JobCondition): boolean => { + if (isComparisonCondition(condition)) + return condition.conditions.every((c) => isValidJobCondition(c)); + if (isMetadataCondition(condition)) { + if (condition.operator === MetadataOperator.Null) + return condition.value == null && condition.key.length > 0; + return condition.value.length > 0 && condition.key.length > 0; + } + return condition.value.length > 0; +}; diff --git a/packages/validators/src/jobs/conditions/status-condition.ts b/packages/validators/src/jobs/conditions/status-condition.ts new file mode 100644 index 000000000..600461e5b --- /dev/null +++ b/packages/validators/src/jobs/conditions/status-condition.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +export const jobStatus = z.enum([ + "completed", + "cancelled", + "skipped", + "in_progress", + "action_required", + "pending", + "failure", + "invalid_job_agent", + "invalid_integration", + "external_run_not_found", +]); + +export const statusCondition = z.object({ + type: z.literal("status"), + operator: z.literal("equals"), + value: jobStatus, +}); + +export type StatusCondition = z.infer; diff --git a/packages/validators/src/jobs/index.ts b/packages/validators/src/jobs/index.ts index 7868e1bc0..7b9c5b0c3 100644 --- a/packages/validators/src/jobs/index.ts +++ b/packages/validators/src/jobs/index.ts @@ -1,3 +1,5 @@ +export * from "./conditions/index.js"; + export enum JobAgentType { GithubApp = "github-app", } @@ -15,6 +17,18 @@ export enum JobStatus { ExternalRunNotFound = "external_run_not_found", } +export type JobStatusType = + | "completed" + | "cancelled" + | "skipped" + | "in_progress" + | "action_required" + | "pending" + | "failure" + | "invalid_job_agent" + | "invalid_integration" + | "external_run_not_found"; + export const JobStatusReadable = { [JobStatus.Completed]: "Completed", [JobStatus.Cancelled]: "Cancelled", diff --git a/packages/validators/src/releases/conditions/comparison-condition.ts b/packages/validators/src/releases/conditions/comparison-condition.ts index 8b2abdff6..f018286c0 100644 --- a/packages/validators/src/releases/conditions/comparison-condition.ts +++ b/packages/validators/src/releases/conditions/comparison-condition.ts @@ -2,10 +2,10 @@ import { z } from "zod"; import type { CreatedAtCondition } from "../../conditions/date-condition.js"; import type { MetadataCondition } from "../../conditions/index.js"; -import type { VersionCondition } from "./version-condition.js"; +import type { VersionCondition } from "../../conditions/version-condition.js"; import { createdAtCondition } from "../../conditions/date-condition.js"; import { metadataCondition } from "../../conditions/index.js"; -import { versionCondition } from "./version-condition.js"; +import { versionCondition } from "../../conditions/version-condition.js"; export const comparisonCondition: z.ZodType = z.lazy(() => z.object({ diff --git a/packages/validators/src/releases/conditions/index.ts b/packages/validators/src/releases/conditions/index.ts index 3ee7679e8..1dee19120 100644 --- a/packages/validators/src/releases/conditions/index.ts +++ b/packages/validators/src/releases/conditions/index.ts @@ -1,3 +1,2 @@ -export * from "./version-condition.js"; export * from "./comparison-condition.js"; export * from "./release-condition.js"; diff --git a/packages/validators/src/releases/conditions/release-condition.ts b/packages/validators/src/releases/conditions/release-condition.ts index 6597e6cdc..01f04851b 100644 --- a/packages/validators/src/releases/conditions/release-condition.ts +++ b/packages/validators/src/releases/conditions/release-condition.ts @@ -2,12 +2,12 @@ import { z } from "zod"; import type { CreatedAtCondition } from "../../conditions/date-condition.js"; import type { MetadataCondition } from "../../conditions/index.js"; +import type { VersionCondition } from "../../conditions/version-condition.js"; import type { ComparisonCondition } from "./comparison-condition.js"; -import type { VersionCondition } from "./version-condition.js"; import { createdAtCondition } from "../../conditions/date-condition.js"; import { metadataCondition } from "../../conditions/index.js"; +import { versionCondition } from "../../conditions/version-condition.js"; import { comparisonCondition } from "./comparison-condition.js"; -import { versionCondition } from "./version-condition.js"; export type ReleaseCondition = | ComparisonCondition From 6496a5f44020a3f7d05d9dd7df884cf41da009ff Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Sat, 26 Oct 2024 17:09:32 -0700 Subject: [PATCH 2/4] mroe stuff --- .../EnvironmentConditionRender.tsx | 8 +- .../JobComparisonConditionRender.tsx | 11 ++ .../job-condition/JobConditionBadge.tsx | 25 ++- .../job-condition/JobConditionRender.tsx | 10 ++ .../JobTargetConditionRender.tsx | 145 ++++++++++++++++++ packages/api/src/router/environment.ts | 7 +- packages/api/src/router/job.ts | 1 + packages/db/src/schema/job.ts | 2 + .../jobs/conditions/comparison-condition.ts | 4 + .../validators/src/jobs/conditions/index.ts | 1 + .../src/jobs/conditions/job-condition.ts | 12 +- .../jobs/conditions/job-target-condition.ts | 9 ++ 12 files changed, 230 insertions(+), 5 deletions(-) create mode 100644 apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobTargetConditionRender.tsx create mode 100644 packages/validators/src/jobs/conditions/job-target-condition.ts diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/EnvironmentConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/EnvironmentConditionRender.tsx index c46c490b6..c06cc4857 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/EnvironmentConditionRender.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/EnvironmentConditionRender.tsx @@ -45,7 +45,7 @@ export const EnvironmentConditionRender: React.FC< const options = environments.map((environment) => ({ key: environment.id, value: environment.id, - display: environment.name, + display: `${environment.name} (${environment.system.name})`, })); const setEnvironment = (environment: string) => @@ -55,11 +55,15 @@ export const EnvironmentConditionRender: React.FC< (environment) => environment.id === condition.value, ); + const selectedDisplay = selectedEnvironment + ? `${selectedEnvironment.name} (${selectedEnvironment.system.name})` + : null; + return ( Status + + addCondition({ + type: JobFilterType.JobTarget, + operator: ColumnOperator.Equals, + value: "", + }) + } + > + Target + addCondition({ diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobConditionBadge.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobConditionBadge.tsx index 8d198b1c9..8d5959791 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobConditionBadge.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobConditionBadge.tsx @@ -8,6 +8,7 @@ import type { DeploymentCondition, EnvironmentCondition, JobCondition, + JobTargetCondition, StatusCondition, } from "@ctrlplane/validators/jobs"; import React from "react"; @@ -32,6 +33,7 @@ import { isCreatedAtCondition, isDeploymentCondition, isEnvironmentCondition, + isJobTargetCondition, isMetadataCondition, isStatusCondition, isVersionCondition, @@ -216,6 +218,7 @@ const StringifiedEnvironmentCondition: React.FC<{ }> = ({ condition }) => { const environmentQ = api.environment.byId.useQuery(condition.value); const environment = environmentQ.data; + const display = `${noCase(environment?.name ?? "")} (${noCase(environment?.system.name ?? "")})`; return ( @@ -223,7 +226,7 @@ const StringifiedEnvironmentCondition: React.FC<{ {operatorVerbs[condition.operator]} - {noCase(environment?.name ?? "")} + {display} ); }; @@ -240,6 +243,23 @@ const StringifiedVersionCondition: React.FC<{ ); +const StringifiedJobTargetCondition: React.FC<{ + condition: JobTargetCondition; +}> = ({ condition }) => { + const targetQ = api.target.byId.useQuery(condition.value); + const target = targetQ.data; + + return ( + + target + + {operatorVerbs[condition.operator]} + + {noCase(target?.name ?? "")} + + ); +}; + const StringifiedJobCondition: React.FC<{ condition: JobCondition; depth?: number; @@ -278,6 +298,9 @@ const StringifiedJobCondition: React.FC<{ if (isVersionCondition(condition)) return ; + if (isJobTargetCondition(condition)) + return ; + return null; }; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobConditionRender.tsx index 01c7291c6..e1504361d 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobConditionRender.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobConditionRender.tsx @@ -6,6 +6,7 @@ import { isCreatedAtCondition, isDeploymentCondition, isEnvironmentCondition, + isJobTargetCondition, isMetadataCondition, isStatusCondition, isVersionCondition, @@ -17,6 +18,7 @@ import { EnvironmentConditionRender } from "./EnvironmentConditionRender"; import { JobComparisonConditionRender } from "./JobComparisonConditionRender"; import { JobCreatedAtConditionRender } from "./JobCreatedAtConditionRender"; import { JobMetadataConditionRender } from "./JobMetadataConditionRender"; +import { JobTargetConditionRender } from "./JobTargetConditionRender"; import { StatusConditionRender } from "./StatusConditionRender"; import { JobReleaseVersionConditionRender } from "./VersionConditionRender"; @@ -96,5 +98,13 @@ export const JobConditionRender: React.FC< /> ); + if (isJobTargetCondition(condition)) + return ( + + ); return null; }; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobTargetConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobTargetConditionRender.tsx new file mode 100644 index 000000000..b15c39446 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobTargetConditionRender.tsx @@ -0,0 +1,145 @@ +import type { JobTargetCondition } from "@ctrlplane/validators/jobs"; +import type { TargetCondition } from "@ctrlplane/validators/targets"; +import { useState } from "react"; +import { useParams } from "next/navigation"; +import { IconLoader2, IconSelector } from "@tabler/icons-react"; +import { isPresent } from "ts-is-present"; + +import { cn } from "@ctrlplane/ui"; +import { Button } from "@ctrlplane/ui/button"; +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList, +} from "@ctrlplane/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@ctrlplane/ui/popover"; +import { + ComparisonOperator, + FilterType, +} from "@ctrlplane/validators/conditions"; +import { + TargetFilterType, + TargetOperator, +} from "@ctrlplane/validators/targets"; + +import type { JobConditionRenderProps } from "./job-condition-props"; +import { api } from "~/trpc/react"; + +export const JobTargetConditionRender: React.FC< + JobConditionRenderProps +> = ({ condition, onChange, className }) => { + const [search, setSearch] = useState(""); + const [open, setOpen] = useState(false); + + const targetQ = api.target.byId.useQuery(condition.value); + const target = targetQ.data; + + const { workspaceSlug, systemSlug } = useParams<{ + workspaceSlug: string; + systemSlug?: string; + }>(); + + const workspaceQ = api.workspace.bySlug.useQuery(workspaceSlug); + const workspace = workspaceQ.data; + + const searchFilter: TargetCondition = { + type: TargetFilterType.Name, + operator: TargetOperator.Like, + value: `%${search}%`, + }; + + // const workspaceTargetsFilter: TargetCondition | undefined = + // search != "" ? searchFilter : undefined; + + const systemQ = api.system.bySlug.useQuery( + { workspaceSlug, systemSlug: systemSlug ?? "" }, + { enabled: systemSlug != null }, + ); + const system = systemQ.data; + const envFilters = + system?.environments.map((env) => env.targetFilter).filter(isPresent) ?? []; + + const systemFilter: TargetCondition = { + type: FilterType.Comparison, + operator: ComparisonOperator.Or, + conditions: envFilters, + }; + + const systemTargetsFilter: TargetCondition | undefined = + system != null + ? { + type: FilterType.Comparison, + operator: ComparisonOperator.And, + conditions: [searchFilter, systemFilter], + } + : undefined; + + const filter = systemTargetsFilter ?? searchFilter; + + const targetsQ = api.target.byWorkspaceId.list.useQuery( + { workspaceId: workspace?.id ?? "", filter, limit: 8 }, + { enabled: workspace != null, placeholderData: (prev) => prev }, + ); + + return ( +
+
+
+ Target is +
+
+ + + + + + + + + {targetsQ.isLoading && ( + + + Loading targets... + + )} + {targetsQ.isSuccess && targetsQ.data.items.length === 0 && ( + No targets found. + )} + {targetsQ.isSuccess && + targetsQ.data.items.map((target) => ( + { + onChange({ ...condition, value: target.id }); + setOpen(false); + }} + > + {target.name} + + ))} + + + + +
+
+
+ ); +}; diff --git a/packages/api/src/router/environment.ts b/packages/api/src/router/environment.ts index f279946b5..b090c02c8 100644 --- a/packages/api/src/router/environment.ts +++ b/packages/api/src/router/environment.ts @@ -455,12 +455,17 @@ export const environmentRouter = createTRPCRouter({ environmentPolicy, eq(environment.policyId, environmentPolicy.id), ) + .innerJoin(system, eq(environment.systemId, system.id)) .where(and(eq(environment.id, input), isNull(environment.deletedAt))) .then(takeFirstOrNull) .then((env) => env == null ? null - : { ...env.environment, policy: env.environment_policy }, + : { + ...env.environment, + policy: env.environment_policy, + system: env.system, + }, ), ), diff --git a/packages/api/src/router/job.ts b/packages/api/src/router/job.ts index 439d4ea38..78717057f 100644 --- a/packages/api/src/router/job.ts +++ b/packages/api/src/router/job.ts @@ -186,6 +186,7 @@ const releaseJobTriggerRouter = createTRPCRouter({ }) .from(releaseJobTrigger) .innerJoin(job, eq(releaseJobTrigger.jobId, job.id)) + .innerJoin(target, eq(releaseJobTrigger.targetId, target.id)) .innerJoin(release, eq(releaseJobTrigger.releaseId, release.id)) .innerJoin(deployment, eq(release.deploymentId, deployment.id)) .innerJoin( diff --git a/packages/db/src/schema/job.ts b/packages/db/src/schema/job.ts index 879774c4e..2dee45d79 100644 --- a/packages/db/src/schema/job.ts +++ b/packages/db/src/schema/job.ts @@ -44,6 +44,7 @@ import { deployment } from "./deployment.js"; import { environment } from "./environment.js"; import { jobAgent } from "./job-agent.js"; import { release } from "./release.js"; +import { target } from "./target.js"; // if adding a new status, update the validators package @ctrlplane/validators/src/jobs/index.ts export const jobStatus = pgEnum("job_status", [ @@ -216,6 +217,7 @@ const buildCondition = (tx: Tx, cond: JobCondition): SQL => { if (cond.type === JobFilterType.Environment) return eq(environment.id, cond.value); if (cond.type === FilterType.Version) return buildVersionCondition(cond); + if (cond.type === JobFilterType.JobTarget) return eq(target.id, cond.value); const subCon = cond.conditions.map((c) => buildCondition(tx, c)); const con = diff --git a/packages/validators/src/jobs/conditions/comparison-condition.ts b/packages/validators/src/jobs/conditions/comparison-condition.ts index 8a7444f5e..229a3a739 100644 --- a/packages/validators/src/jobs/conditions/comparison-condition.ts +++ b/packages/validators/src/jobs/conditions/comparison-condition.ts @@ -7,6 +7,7 @@ import type { import type { VersionCondition } from "../../conditions/version-condition.js"; import type { DeploymentCondition } from "./deployment-condition.js"; import type { EnvironmentCondition } from "./environment-condition.js"; +import type { JobTargetCondition } from "./job-target-condition.js"; import type { StatusCondition } from "./status-condition.js"; import { createdAtCondition, @@ -15,6 +16,7 @@ import { import { versionCondition } from "../../conditions/version-condition.js"; import { deploymentCondition } from "./deployment-condition.js"; import { environmentCondition } from "./environment-condition.js"; +import { jobTargetCondition } from "./job-target-condition.js"; import { statusCondition } from "./status-condition.js"; export const comparisonCondition: z.ZodType = z.lazy(() => @@ -31,6 +33,7 @@ export const comparisonCondition: z.ZodType = z.lazy(() => deploymentCondition, environmentCondition, versionCondition, + jobTargetCondition, ]), ), }), @@ -48,5 +51,6 @@ export type ComparisonCondition = { | DeploymentCondition | EnvironmentCondition | VersionCondition + | JobTargetCondition >; }; diff --git a/packages/validators/src/jobs/conditions/index.ts b/packages/validators/src/jobs/conditions/index.ts index 9df750120..80f637a89 100644 --- a/packages/validators/src/jobs/conditions/index.ts +++ b/packages/validators/src/jobs/conditions/index.ts @@ -3,3 +3,4 @@ export * from "./comparison-condition.js"; export * from "./status-condition.js"; export * from "./deployment-condition.js"; export * from "./environment-condition.js"; +export * from "./job-target-condition.js"; diff --git a/packages/validators/src/jobs/conditions/job-condition.ts b/packages/validators/src/jobs/conditions/job-condition.ts index 2437a4796..d5cb9bdd3 100644 --- a/packages/validators/src/jobs/conditions/job-condition.ts +++ b/packages/validators/src/jobs/conditions/job-condition.ts @@ -8,6 +8,7 @@ import type { VersionCondition } from "../../conditions/version-condition.js"; import type { ComparisonCondition } from "./comparison-condition.js"; import type { DeploymentCondition } from "./deployment-condition.js"; import type { EnvironmentCondition } from "./environment-condition.js"; +import type { JobTargetCondition } from "./job-target-condition.js"; import type { StatusCondition } from "./status-condition.js"; import { ComparisonOperator, @@ -21,6 +22,7 @@ import { versionCondition } from "../../conditions/version-condition.js"; import { comparisonCondition } from "./comparison-condition.js"; import { deploymentCondition } from "./deployment-condition.js"; import { environmentCondition } from "./environment-condition.js"; +import { jobTargetCondition } from "./job-target-condition.js"; import { statusCondition } from "./status-condition.js"; export type JobCondition = @@ -30,7 +32,8 @@ export type JobCondition = | StatusCondition | DeploymentCondition | EnvironmentCondition - | VersionCondition; + | VersionCondition + | JobTargetCondition; export const jobCondition = z.union([ comparisonCondition, @@ -40,6 +43,7 @@ export const jobCondition = z.union([ deploymentCondition, environmentCondition, versionCondition, + jobTargetCondition, ]); export const defaultCondition: JobCondition = { @@ -53,6 +57,7 @@ export enum JobFilterType { Status = "status", Deployment = "deployment", Environment = "environment", + JobTarget = "target", } export const isEmptyCondition = (condition: JobCondition): boolean => @@ -88,6 +93,11 @@ export const isVersionCondition = ( condition: JobCondition, ): condition is VersionCondition => condition.type === FilterType.Version; +export const isJobTargetCondition = ( + condition: JobCondition, +): condition is JobTargetCondition => + condition.type === JobFilterType.JobTarget; + // Check if converting to a comparison condition will exceed the max depth // including any nested conditions export const doesConvertingToComparisonRespectMaxDepth = ( diff --git a/packages/validators/src/jobs/conditions/job-target-condition.ts b/packages/validators/src/jobs/conditions/job-target-condition.ts new file mode 100644 index 000000000..ada2257d6 --- /dev/null +++ b/packages/validators/src/jobs/conditions/job-target-condition.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const jobTargetCondition = z.object({ + type: z.literal("target"), + operator: z.literal("equals"), + value: z.string().uuid(), +}); + +export type JobTargetCondition = z.infer; From fd102b50f7cb0ed51c9716db31a0ac2cff9b7562 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Sat, 26 Oct 2024 17:49:35 -0700 Subject: [PATCH 3/4] cleanup --- .../_components/filter/ChoiceConditionRender.tsx | 2 +- .../_components/filter/DateConditionRender.tsx | 2 +- .../_components/filter/VersionConditionRender.tsx | 2 +- .../job-condition/JobComparisonConditionRender.tsx | 3 ++- .../_components/job-condition/JobConditionBadge.tsx | 2 +- .../_components/job-condition/JobConditionRender.tsx | 5 +---- .../job-condition/JobTargetConditionRender.tsx | 5 ++++- .../_components/job-condition/job-condition-props.ts | 1 - .../release-condition/ReleaseConditionRender.tsx | 2 +- ...nRender.tsx => ReleaseVersionConditionRender.tsx} | 0 .../releases/[versionId]/FlowPolicyNode.tsx | 4 ++-- packages/api/src/router/job.ts | 12 ++++++------ .../validators/src/conditions/version-condition.ts | 2 +- packages/validators/src/jobs/index.ts | 12 +----------- 14 files changed, 22 insertions(+), 32 deletions(-) rename apps/webservice/src/app/[workspaceSlug]/_components/release-condition/{VersionConditionRender.tsx => ReleaseVersionConditionRender.tsx} (100%) diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/filter/ChoiceConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/filter/ChoiceConditionRender.tsx index 529082d96..1bb9c3815 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/filter/ChoiceConditionRender.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/filter/ChoiceConditionRender.tsx @@ -36,7 +36,7 @@ export const ChoiceConditionRender: React.FC = ({
- {capitalCase(type)} is + {capitalCase(type)} is
diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/filter/DateConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/filter/DateConditionRender.tsx index c55a35f5b..0bd292974 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/filter/DateConditionRender.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/filter/DateConditionRender.tsx @@ -59,7 +59,7 @@ export const DateConditionRender: React.FC = ({
- {type} + {type}
diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobComparisonConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobComparisonConditionRender.tsx index 44a12a2f6..65ec2b20a 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobComparisonConditionRender.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobComparisonConditionRender.tsx @@ -79,6 +79,8 @@ export const JobComparisonConditionRender: React.FC< const cond = condition.conditions[index]; if (!cond) return; + if (!doesConvertingToComparisonRespectMaxDepth(depth + 1, cond)) return; + const newComparisonCondition: ComparisonCondition = { type: FilterType.Comparison, operator: ComparisonOperator.And, @@ -187,7 +189,6 @@ export const JobComparisonConditionRender: React.FC< key={index} condition={subCond} onChange={(c) => updateCondition(index, c)} - onRemove={() => removeCondition(index)} depth={depth + 1} className={cn(depth === 0 ? "col-span-11" : "col-span-10")} /> diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobConditionBadge.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobConditionBadge.tsx index 8d5959791..e996aaa61 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobConditionBadge.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobConditionBadge.tsx @@ -64,7 +64,7 @@ const ConditionBadge: React.FC<{ }> = ({ children }) => ( {children} diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobConditionRender.tsx index e1504361d..dac3c412a 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobConditionRender.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobConditionRender.tsx @@ -28,13 +28,12 @@ import { JobReleaseVersionConditionRender } from "./VersionConditionRender"; */ export const JobConditionRender: React.FC< JobConditionRenderProps -> = ({ condition, onChange, onRemove, depth = 0, className }) => { +> = ({ condition, onChange, depth = 0, className }) => { if (isComparisonCondition(condition)) return ( @@ -45,7 +44,6 @@ export const JobConditionRender: React.FC< @@ -56,7 +54,6 @@ export const JobConditionRender: React.FC< diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobTargetConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobTargetConditionRender.tsx index b15c39446..bcf4fc71e 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobTargetConditionRender.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobTargetConditionRender.tsx @@ -3,6 +3,7 @@ import type { TargetCondition } from "@ctrlplane/validators/targets"; import { useState } from "react"; import { useParams } from "next/navigation"; import { IconLoader2, IconSelector } from "@tabler/icons-react"; +import { useDebounce } from "react-use"; import { isPresent } from "ts-is-present"; import { cn } from "@ctrlplane/ui"; @@ -31,6 +32,8 @@ export const JobTargetConditionRender: React.FC< JobConditionRenderProps > = ({ condition, onChange, className }) => { const [search, setSearch] = useState(""); + const [searchDebounced, setSearchDebounced] = useState(""); + useDebounce(() => setSearchDebounced(search), 300, [search]); const [open, setOpen] = useState(false); const targetQ = api.target.byId.useQuery(condition.value); @@ -47,7 +50,7 @@ export const JobTargetConditionRender: React.FC< const searchFilter: TargetCondition = { type: TargetFilterType.Name, operator: TargetOperator.Like, - value: `%${search}%`, + value: `%${searchDebounced}%`, }; // const workspaceTargetsFilter: TargetCondition | undefined = diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/job-condition-props.ts b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/job-condition-props.ts index 14de763c1..8934aed95 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/job-condition-props.ts +++ b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/job-condition-props.ts @@ -3,7 +3,6 @@ import type { JobCondition } from "@ctrlplane/validators/jobs"; export type JobConditionRenderProps = { condition: T; onChange: (condition: T) => void; - onRemove?: () => void; depth?: number; className?: string; }; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseConditionRender.tsx index 5c17dba8a..645fd820a 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseConditionRender.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseConditionRender.tsx @@ -12,7 +12,7 @@ import type { ReleaseConditionRenderProps } from "./release-condition-props"; import { ComparisonConditionRender } from "./ComparisonConditionRender"; import { CreatedAtConditionRender } from "./ReleaseCreatedAtConditionRender"; import { ReleaseMetadataConditionRender } from "./ReleaseMetadataConditionRender"; -import { ReleaseVersionConditionRender } from "./VersionConditionRender"; +import { ReleaseVersionConditionRender } from "./ReleaseVersionConditionRender"; /** * The parent container should have min width of 1000px diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/VersionConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseVersionConditionRender.tsx similarity index 100% rename from apps/webservice/src/app/[workspaceSlug]/_components/release-condition/VersionConditionRender.tsx rename to apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseVersionConditionRender.tsx diff --git a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/[versionId]/FlowPolicyNode.tsx b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/[versionId]/FlowPolicyNode.tsx index 97f8723e3..4ae71db9d 100644 --- a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/[versionId]/FlowPolicyNode.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/[versionId]/FlowPolicyNode.tsx @@ -159,7 +159,7 @@ const EvaluateFilterCheck: React.FC<{
); -const MinSucessCheck: React.FC = ({ +const MinSuccessCheck: React.FC = ({ successMinimum, successType, release, @@ -299,7 +299,7 @@ export const PolicyNode: React.FC = ({ data }) => { {hasFilterCheck && ( )} - {!noMinSuccess && } + {!noMinSuccess && } {!noRollout && } {!noApproval && } diff --git a/packages/api/src/router/job.ts b/packages/api/src/router/job.ts index 78717057f..575b438e5 100644 --- a/packages/api/src/router/job.ts +++ b/packages/api/src/router/job.ts @@ -99,15 +99,15 @@ const processReleaseJobTriggerWithAdditionalDataRows = ( job: { ...v[0]!.job, metadata: _.chain(v) - .filter((v) => isPresent(v.job_metadata)) - .groupBy((v) => v.job_metadata!.id) - .map((v) => v[0]!.job_metadata!) + .map((v) => v.job_metadata) + .filter(isPresent) + .uniqBy((v) => v.id) .value(), status: v[0]!.job.status as JobStatus, variables: _.chain(v) - .filter((v) => isPresent(v.job_variable)) - .groupBy((v) => v.job_variable!.id) - .map((v) => v[0]!.job_variable!) + .map((v) => v.job_variable) + .filter(isPresent) + .uniqBy((v) => v.id) .value(), }, jobAgent: v[0]!.job_agent, diff --git a/packages/validators/src/conditions/version-condition.ts b/packages/validators/src/conditions/version-condition.ts index c51b95e79..d012a9fe9 100644 --- a/packages/validators/src/conditions/version-condition.ts +++ b/packages/validators/src/conditions/version-condition.ts @@ -14,4 +14,4 @@ export enum VersionOperator { Equals = "equals", } -export type VersionOperatorType = "like" | "regex" | "equals"; +export type VersionOperatorType = z.infer["operator"]; diff --git a/packages/validators/src/jobs/index.ts b/packages/validators/src/jobs/index.ts index 7b9c5b0c3..93623f3c4 100644 --- a/packages/validators/src/jobs/index.ts +++ b/packages/validators/src/jobs/index.ts @@ -17,17 +17,7 @@ export enum JobStatus { ExternalRunNotFound = "external_run_not_found", } -export type JobStatusType = - | "completed" - | "cancelled" - | "skipped" - | "in_progress" - | "action_required" - | "pending" - | "failure" - | "invalid_job_agent" - | "invalid_integration" - | "external_run_not_found"; +export type JobStatusType = `${JobStatus}`; export const JobStatusReadable = { [JobStatus.Completed]: "Completed", From 2977c73ea286f65e4790ae0753a89c0f6e308f45 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Sat, 26 Oct 2024 17:54:10 -0700 Subject: [PATCH 4/4] cleanup --- .../_components/job-condition/JobTargetConditionRender.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobTargetConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobTargetConditionRender.tsx index bcf4fc71e..55db292d6 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobTargetConditionRender.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobTargetConditionRender.tsx @@ -53,9 +53,6 @@ export const JobTargetConditionRender: React.FC< value: `%${searchDebounced}%`, }; - // const workspaceTargetsFilter: TargetCondition | undefined = - // search != "" ? searchFilter : undefined; - const systemQ = api.system.bySlug.useQuery( { workspaceSlug, systemSlug: systemSlug ?? "" }, { enabled: systemSlug != null },