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 00000000..54e3a6f0 --- /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 fe9ca984..f0950169 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 47bc1b6b..f75caf28 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 f82f5934..1bb9c381 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/DateConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/filter/DateConditionRender.tsx index c55a35f5..0bd29297 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}
+ + + + + Equals + Like + Regex + + +
+
+ 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 00000000..d8f2cd9c --- /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 00000000..c06cc485 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/EnvironmentConditionRender.tsx @@ -0,0 +1,72 @@ +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} (${environment.system.name})`, + })); + + const setEnvironment = (environment: string) => + onChange({ ...condition, value: environment }); + + const selectedEnvironment = environments.find( + (environment) => environment.id === condition.value, + ); + + const selectedDisplay = selectedEnvironment + ? `${selectedEnvironment.name} (${selectedEnvironment.system.name})` + : null; + + 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 00000000..65ec2b20 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobComparisonConditionRender.tsx @@ -0,0 +1,392 @@ +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; + + if (!doesConvertingToComparisonRespectMaxDepth(depth + 1, 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)} + 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.JobTarget, + operator: ColumnOperator.Equals, + value: "", + }) + } + > + Target + + + 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 00000000..e996aaa6 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobConditionBadge.tsx @@ -0,0 +1,323 @@ +import type { + CreatedAtCondition, + MetadataCondition, + VersionCondition, +} from "@ctrlplane/validators/conditions"; +import type { + ComparisonCondition, + DeploymentCondition, + EnvironmentCondition, + JobCondition, + JobTargetCondition, + 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, + isJobTargetCondition, + 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; + const display = `${noCase(environment?.name ?? "")} (${noCase(environment?.system.name ?? "")})`; + + return ( + + environment + + {operatorVerbs[condition.operator]} + + {display} + + ); +}; + +const StringifiedVersionCondition: React.FC<{ + condition: VersionCondition; +}> = ({ condition }) => ( + + version + + {operatorVerbs[condition.operator]} + + {condition.value} + +); + +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; + 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 ; + + if (isJobTargetCondition(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 00000000..5437b47a --- /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 00000000..dac3c412 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobConditionRender.tsx @@ -0,0 +1,107 @@ +import type { JobCondition } from "@ctrlplane/validators/jobs"; +import React from "react"; + +import { + isComparisonCondition, + isCreatedAtCondition, + isDeploymentCondition, + isEnvironmentCondition, + isJobTargetCondition, + 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 { JobTargetConditionRender } from "./JobTargetConditionRender"; +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, 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 ( + + ); + + if (isJobTargetCondition(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 00000000..a58a6c4d --- /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 00000000..5f63cb0b --- /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/JobTargetConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/JobTargetConditionRender.tsx new file mode 100644 index 00000000..55db292d --- /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 { useDebounce } from "react-use"; +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 [searchDebounced, setSearchDebounced] = useState(""); + useDebounce(() => setSearchDebounced(search), 300, [search]); + 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: `%${searchDebounced}%`, + }; + + 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/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 00000000..03d4faaf --- /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 00000000..4a6a5595 --- /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 00000000..8934aed9 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/job-condition/job-condition-props.ts @@ -0,0 +1,8 @@ +import type { JobCondition } from "@ctrlplane/validators/jobs"; + +export type JobConditionRenderProps = { + condition: T; + onChange: (condition: T) => 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 00000000..3326eb98 --- /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 9b32fca5..1925ebbc 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 5f3481e3..6d2d8667 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 b273d357..645fd820 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 "./ReleaseVersionConditionRender"; /** * 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: VersionOperatorType) => + onChange({ ...condition, operator }); + const setValue = (value: string) => onChange({ ...condition, value }); + + return ( + + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/VersionConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/VersionConditionRender.tsx deleted file mode 100644 index ff675ee7..00000000 --- a/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/VersionConditionRender.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import type { VersionCondition } from "@ctrlplane/validators/releases"; -import React from "react"; - -import { cn } from "@ctrlplane/ui"; -import { Input } from "@ctrlplane/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@ctrlplane/ui/select"; -import { ReleaseOperator } from "@ctrlplane/validators/releases"; - -import type { ReleaseConditionRenderProps } from "./release-condition-props"; - -export const VersionConditionRender: React.FC< - ReleaseConditionRenderProps -> = ({ condition, onChange, className }) => { - const setOperator = ( - operator: - | ReleaseOperator.Equals - | ReleaseOperator.Like - | ReleaseOperator.Regex, - ) => 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 9ee735a5..bf6e4d0f 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 d010b0e4..5401e6a6 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 87696247..f4118308 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 f3f787e5..4ae71db9 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,15 +159,16 @@ const EvaluateFilterCheck: React.FC<{
); -const MinSucessCheck: React.FC = ({ +const MinSuccessCheck: React.FC = ({ successMinimum, successType, 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)); @@ -298,7 +299,7 @@ export const PolicyNode: React.FC = ({ data }) => { {hasFilterCheck && ( )} - {!noMinSuccess && } + {!noMinSuccess && } {!noRollout && } {!noApproval && } 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 48af8018..c5d99ddf 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 def2e6cb..ec5055b6 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 @@ -8,6 +8,7 @@ import { IconChevronRight, IconDots, IconExternalLink, + IconFilter, IconLoader2, } from "@tabler/icons-react"; import { capitalCase } from "change-case"; @@ -18,6 +19,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"; @@ -39,21 +43,23 @@ const CollapsibleTableRow: React.FC = ({ deploymentName, release, }) => { + const { filter } = useJobFilter(); const { setJobId } = useJobDrawer(); const pathname = usePathname(); const [isExpanded, setIsExpanded] = useState(false); const releaseJobTriggerQuery = api.job.config.byReleaseId.useQuery( - release.id, - { refetchInterval: 5_000 }, + { releaseId: release.id, filter }, + { refetchInterval: 5_000, placeholderData: (prev) => prev }, ); const jobs = releaseJobTriggerQuery.data?.filter( (job) => job.environmentId === environment.id, ); - const approvals = api.environment.policy.approval.byReleaseId.useQuery({ + const approvalsQ = api.environment.policy.approval.byReleaseId.useQuery({ releaseId: release.id, }); - const environmentApprovals = approvals.data?.filter( + const approvals = approvalsQ.data ?? []; + const environmentApprovals = approvals.filter( (approval) => approval.policyId === environment.policyId, ); @@ -82,7 +88,7 @@ const CollapsibleTableRow: React.FC = ({ {environment.name}
- {environmentApprovals?.map((approval) => ( + {environmentApprovals.map((approval) => ( = ({ deploymentName, environments, }) => { + const { filter, setFilter } = useJobFilter(); + return ( -
- - {environments.map((environment) => ( - - ))} - -
+ <> +
+ +
+ + {filter != null && } +
+
+
+ + + {environments.map((environment) => ( + + ))} + +
+ ); }; 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 62654bba..b25a0a6f 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"; @@ -59,12 +57,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 18fdaea1..b090c02c 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, + }, ), ), @@ -497,6 +502,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 972c4fa6..575b438e 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) + .map((v) => v.job_metadata) + .filter(isPresent) + .uniqBy((v) => v.id) + .value(), status: v[0]!.job.status as JobStatus, - variables: v.map((t) => t.job_variable).filter(isPresent), + variables: _.chain(v) + .map((v) => v.job_variable) + .filter(isPresent) + .uniqBy((v) => v.id) + .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,33 @@ 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(target, eq(releaseJobTrigger.targetId, target.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 +328,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 +347,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 +608,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 +685,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 618812cc..2dee45d7 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,20 @@ 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"; +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", [ @@ -103,3 +136,100 @@ 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); + if (cond.type === JobFilterType.JobTarget) return eq(target.id, cond.value); + + 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 09b8996a..8e8e2ea7 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 781d353b..7b2fd8ea 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 1c0e09c4..302ece1f 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 ef3e3fb5..54d00f18 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 61% rename from packages/validators/src/releases/conditions/version-condition.ts rename to packages/validators/src/conditions/version-condition.ts index 9884f720..d012a9fe 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 = z.infer["operator"]; 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 00000000..229a3a73 --- /dev/null +++ b/packages/validators/src/jobs/conditions/comparison-condition.ts @@ -0,0 +1,56 @@ +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 { JobTargetCondition } from "./job-target-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 { jobTargetCondition } from "./job-target-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, + jobTargetCondition, + ]), + ), + }), +); + +export type ComparisonCondition = { + type: "comparison"; + operator: "and" | "or"; + not?: boolean; + conditions: Array< + | ComparisonCondition + | MetadataCondition + | CreatedAtCondition + | StatusCondition + | DeploymentCondition + | EnvironmentCondition + | VersionCondition + | JobTargetCondition + >; +}; 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 00000000..6c231d7e --- /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 00000000..96aedad4 --- /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 00000000..80f637a8 --- /dev/null +++ b/packages/validators/src/jobs/conditions/index.ts @@ -0,0 +1,6 @@ +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"; +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 new file mode 100644 index 00000000..d5cb9bdd --- /dev/null +++ b/packages/validators/src/jobs/conditions/job-condition.ts @@ -0,0 +1,126 @@ +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 { JobTargetCondition } from "./job-target-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 { jobTargetCondition } from "./job-target-condition.js"; +import { statusCondition } from "./status-condition.js"; + +export type JobCondition = + | ComparisonCondition + | MetadataCondition + | CreatedAtCondition + | StatusCondition + | DeploymentCondition + | EnvironmentCondition + | VersionCondition + | JobTargetCondition; + +export const jobCondition = z.union([ + comparisonCondition, + metadataCondition, + createdAtCondition, + statusCondition, + deploymentCondition, + environmentCondition, + versionCondition, + jobTargetCondition, +]); + +export const defaultCondition: JobCondition = { + type: FilterType.Comparison, + operator: ComparisonOperator.And, + not: false, + conditions: [], +}; + +export enum JobFilterType { + Status = "status", + Deployment = "deployment", + Environment = "environment", + JobTarget = "target", +} + +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; + +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 = ( + 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/job-target-condition.ts b/packages/validators/src/jobs/conditions/job-target-condition.ts new file mode 100644 index 00000000..ada2257d --- /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; 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 00000000..600461e5 --- /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 7868e1bc..93623f3c 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,8 @@ export enum JobStatus { ExternalRunNotFound = "external_run_not_found", } +export type JobStatusType = `${JobStatus}`; + 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 8b2abdff..f018286c 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 3ee7679e..1dee1912 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 6597e6cd..01f04851 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