From 42dceb0b661208570709b72666f3f32d5d9178b5 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Fri, 4 Oct 2024 12:42:09 -0700 Subject: [PATCH 1/3] fix: Release filtering --- .../_components/CreateRelease.tsx | 6 +- .../ComparisonConditionRender.tsx | 364 ++++++++++++++++++ .../CreatedAtConditionRender.tsx | 99 +++++ .../MetadataConditionRender.tsx | 137 +++++++ .../ReleaseConditionBadge.tsx | 226 +++++++++++ .../ReleaseConditionDialog.tsx | 207 ++++++++++ .../ReleaseConditionRender.tsx | 69 ++++ .../VersionConditionRender.tsx | 66 ++++ .../release-condition-props.ts | 9 + .../release-condition/useReleaseFilter.ts | 56 +++ .../dependencies/DependencyDiagram.tsx | 4 +- .../[systemSlug]/deployments/TableRelease.tsx | 262 ------------- .../DeploymentPageContent.tsx | 41 +- .../[deploymentSlug]/DistroBarChart.tsx | 2 +- packages/api/src/release-filter.ts | 0 packages/api/src/router/release.ts | 66 +++- packages/db/src/schema/release.ts | 129 ++++++- packages/ui/src/date-time-picker/calendar.tsx | 4 +- .../ui/src/date-time-picker/date-field.tsx | 9 +- .../src/date-time-picker/date-time-picker.tsx | 11 +- packages/validators/package.json | 4 + .../conditions/comparison-condition.ts | 36 ++ .../conditions/created-at-condition.ts | 15 + .../src/releases/conditions/index.ts | 5 + .../releases/conditions/metadata-condition.ts | 54 +++ .../releases/conditions/release-condition.ts | 103 +++++ .../releases/conditions/version-condition.ts | 9 + packages/validators/src/releases/index.ts | 1 + 28 files changed, 1697 insertions(+), 297 deletions(-) create mode 100644 apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ComparisonConditionRender.tsx create mode 100644 apps/webservice/src/app/[workspaceSlug]/_components/release-condition/CreatedAtConditionRender.tsx create mode 100644 apps/webservice/src/app/[workspaceSlug]/_components/release-condition/MetadataConditionRender.tsx create mode 100644 apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseConditionBadge.tsx create mode 100644 apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseConditionDialog.tsx create mode 100644 apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseConditionRender.tsx create mode 100644 apps/webservice/src/app/[workspaceSlug]/_components/release-condition/VersionConditionRender.tsx create mode 100644 apps/webservice/src/app/[workspaceSlug]/_components/release-condition/release-condition-props.ts create mode 100644 apps/webservice/src/app/[workspaceSlug]/_components/release-condition/useReleaseFilter.ts delete mode 100644 apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/TableRelease.tsx create mode 100644 packages/api/src/release-filter.ts create mode 100644 packages/validators/src/releases/conditions/comparison-condition.ts create mode 100644 packages/validators/src/releases/conditions/created-at-condition.ts create mode 100644 packages/validators/src/releases/conditions/index.ts create mode 100644 packages/validators/src/releases/conditions/metadata-condition.ts create mode 100644 packages/validators/src/releases/conditions/release-condition.ts create mode 100644 packages/validators/src/releases/conditions/version-condition.ts create mode 100644 packages/validators/src/releases/index.ts diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/CreateRelease.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/CreateRelease.tsx index 8f5dea988..64e4833a1 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/CreateRelease.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/CreateRelease.tsx @@ -143,8 +143,8 @@ export const CreateReleaseDialog: React.FC<{ }); useEffect(() => { - if (latestRelease.data != null) - latestRelease.data.at(0)?.releaseDependencies.forEach((rd) => { + if ((latestRelease.data?.items.length ?? 0) > 0) + latestRelease.data?.items[0]!.releaseDependencies.forEach((rd) => { append({ ...rd, targetMetadataGroupId: rd.targetMetadataGroupId ?? undefined, @@ -355,7 +355,7 @@ export const CreateReleaseDialog: React.FC<{ deploymentId: "", rule: "", ruleType: - valid(latestRelease.data?.at(0)?.version) != null + valid(latestRelease.data?.items[0]?.version) != null ? "semver" : "regex", }) diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ComparisonConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ComparisonConditionRender.tsx new file mode 100644 index 000000000..dd1e433ba --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ComparisonConditionRender.tsx @@ -0,0 +1,364 @@ +import type { + ComparisonCondition, + ReleaseCondition, +} from "@ctrlplane/validators/releases"; +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 { + doesConvertingToComparisonRespectMaxDepth, + isComparisonCondition, + ReleaseFilterType, + ReleaseOperator, +} from "@ctrlplane/validators/releases"; + +import type { ReleaseConditionRenderProps } from "./release-condition-props"; +import { ReleaseConditionRender } from "./ReleaseConditionRender"; + +export const ComparisonConditionRender: React.FC< + ReleaseConditionRenderProps +> = ({ condition, onChange, depth = 0, className }) => { + const setOperator = (operator: ReleaseOperator.And | ReleaseOperator.Or) => + onChange({ + ...condition, + operator, + }); + + const updateCondition = (index: number, changedCondition: ReleaseCondition) => + onChange({ + ...condition, + conditions: condition.conditions.map((c, i) => + i === index ? changedCondition : c, + ), + }); + + const addCondition = (changedCondition: ReleaseCondition) => + 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: ReleaseFilterType.Comparison, + operator: ReleaseOperator.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: ReleaseFilterType.Comparison, + operator: ReleaseOperator.And, + not: true, + conditions: [cond], + }; + + const newCondition = { + ...condition, + conditions: condition.conditions.map((c, i) => + i === index ? newNotComparisonCondition : c, + ), + }; + onChange(newCondition); + }; + + 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: ReleaseFilterType.Metadata, + operator: ReleaseOperator.Equals, + key: "", + value: "", + }) + } + > + Metadata + + + addCondition({ + type: ReleaseFilterType.CreatedAt, + operator: ReleaseOperator.Before, + value: new Date().toISOString(), + }) + } + > + Created at + + + addCondition({ + type: ReleaseFilterType.Version, + operator: ReleaseOperator.Equals, + value: "", + }) + } + > + Version + + {/* + addCondition({ + type: ReleaseFilterType.Kind, + operator: ReleaseOperator.Equals, + value: "", + }) + } + > + Kind + + + addCondition({ + type: TargetFilterType.Name, + operator: TargetOperator.Like, + value: "", + }) + } + > + Name + + + addCondition({ + type: TargetFilterType.Provider, + operator: TargetOperator.Equals, + value: "", + }) + } + > + Provider + */} + {depth < 2 && ( + + addCondition({ + type: ReleaseFilterType.Comparison, + operator: ReleaseOperator.And, + conditions: [], + not: false, + }) + } + > + Filter group + + )} + {depth < 2 && ( + + addCondition({ + type: ReleaseFilterType.Comparison, + operator: ReleaseOperator.And, + not: true, + conditions: [], + }) + } + > + Not group + + )} + + + +
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/CreatedAtConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/CreatedAtConditionRender.tsx new file mode 100644 index 000000000..65d9a5816 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/CreatedAtConditionRender.tsx @@ -0,0 +1,99 @@ +import type { CreatedAtCondition } from "@ctrlplane/validators/releases"; +import type { DateValue } from "@internationalized/date"; +import { ZonedDateTime } from "@internationalized/date"; +import ms from "ms"; + +import { cn } from "@ctrlplane/ui"; +import { DateTimePicker } from "@ctrlplane/ui/date-time-picker/date-time-picker"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@ctrlplane/ui/select"; +import { ReleaseOperator } from "@ctrlplane/validators/releases"; + +import type { ReleaseConditionRenderProps } from "./release-condition-props"; + +const toZonedDateTime = (date: Date): ZonedDateTime => { + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const offset = -date.getTimezoneOffset() * ms("1m"); + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + const hour = date.getHours(); + const minute = date.getMinutes(); + const second = date.getSeconds(); + const millisecond = date.getMilliseconds(); + + return new ZonedDateTime( + year, + month, + day, + timeZone, + offset, + hour, + minute, + second, + millisecond, + ); +}; + +export const CreatedAtConditionRender: React.FC< + ReleaseConditionRenderProps +> = ({ condition, onChange, className }) => { + const setDate = (t: DateValue) => + onChange({ + ...condition, + value: t + .toDate(Intl.DateTimeFormat().resolvedOptions().timeZone) + .toISOString(), + }); + + const setOperator = ( + operator: + | ReleaseOperator.Before + | ReleaseOperator.After + | ReleaseOperator.BeforeOrOn + | ReleaseOperator.AfterOrOn, + ) => onChange({ ...condition, operator }); + + return ( +
+
+
+ Created at +
+
+ +
+
+ +
+
+
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/MetadataConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/MetadataConditionRender.tsx new file mode 100644 index 000000000..747025edb --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/MetadataConditionRender.tsx @@ -0,0 +1,137 @@ +import type { MetadataCondition } from "@ctrlplane/validators/releases"; +import { useState } from "react"; +import { useParams } from "next/navigation"; + +import { cn } from "@ctrlplane/ui"; +import { Button } from "@ctrlplane/ui/button"; +import { Input } from "@ctrlplane/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@ctrlplane/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@ctrlplane/ui/select"; +import { TargetOperator } from "@ctrlplane/validators/targets"; + +import type { ReleaseConditionRenderProps } from "./release-condition-props"; +import { api } from "~/trpc/react"; +import { useMatchSorter } from "~/utils/useMatchSorter"; + +export const MetadataConditionRender: React.FC< + ReleaseConditionRenderProps +> = ({ condition, onChange, className }) => { + const { workspaceSlug } = useParams<{ workspaceSlug: string }>(); + const workspace = api.workspace.bySlug.useQuery(workspaceSlug); + + const setKey = (key: string) => onChange({ ...condition, key }); + + const setValue = (value: string) => + condition.operator !== TargetOperator.Null && + onChange({ ...condition, value }); + + const setOperator = ( + operator: + | TargetOperator.Equals + | TargetOperator.Like + | TargetOperator.Regex + | TargetOperator.Null, + ) => + operator === TargetOperator.Null + ? onChange({ ...condition, operator, value: undefined }) + : onChange({ ...condition, operator, value: condition.value ?? "" }); + + const [open, setOpen] = useState(false); + const metadataKeys = api.target.metadataKeys.useQuery( + workspace.data?.id ?? "", + { enabled: workspace.isSuccess && workspace.data != null }, + ); + const filteredMetadataKeys = useMatchSorter( + metadataKeys.data ?? [], + condition.key, + ); + + return ( +
+
+
+ + + setKey(e.target.value)} + className="w-full cursor-pointer rounded-l-sm rounded-r-none" + /> + + e.preventDefault()} + > + {filteredMetadataKeys.map((k) => ( + + ))} + + +
+
+ +
+ + {condition.operator !== TargetOperator.Null ? ( +
+ setValue(e.target.value)} + className="rounded-l-none rounded-r-sm hover:bg-neutral-800/50" + /> +
+ ) : ( +
+ )} +
+
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseConditionBadge.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseConditionBadge.tsx new file mode 100644 index 000000000..ba90db6f0 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseConditionBadge.tsx @@ -0,0 +1,226 @@ +import type { + ComparisonCondition, + CreatedAtCondition, + MetadataCondition, + ReleaseCondition, + VersionCondition, +} from "@ctrlplane/validators/releases"; +import React from "react"; +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 { + isComparisonCondition, + isCreatedAtCondition, + isMetadataCondition, + isVersionCondition, + ReleaseOperator, +} from "@ctrlplane/validators/releases"; + +const operatorVerbs = { + [ReleaseOperator.And]: "and", + [ReleaseOperator.Or]: "or", + [ReleaseOperator.Equals]: "is", + [ReleaseOperator.Null]: ( + + is null + + ), + [ReleaseOperator.Regex]: "matches", + [ReleaseOperator.Like]: "contains", + [ReleaseOperator.After]: "after", + [ReleaseOperator.Before]: "before", + [ReleaseOperator.AfterOrOn]: "after or on", + [ReleaseOperator.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 StringifiedVersionCondition: React.FC<{ + condition: VersionCondition; +}> = ({ condition }) => ( + + version + + {operatorVerbs[condition.operator]} + + {condition.value} + +); + +const StringifiedReleaseCondition: React.FC<{ + condition: ReleaseCondition; + 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 (isVersionCondition(condition)) + return ; +}; + +export const ReleaseConditionBadge: React.FC<{ + condition: ReleaseCondition; + tabbed?: boolean; +}> = ({ condition, tabbed = false }) => ( + + +
+ +
+
+ +
+ +
+
+
+); diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseConditionDialog.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseConditionDialog.tsx new file mode 100644 index 000000000..d98d1ac12 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseConditionDialog.tsx @@ -0,0 +1,207 @@ +import type { ReleaseCondition } from "@ctrlplane/validators/releases"; +import React, { useState } from "react"; + +import { Button } from "@ctrlplane/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@ctrlplane/ui/dialog"; +import { + defaultCondition, + isValidReleaseCondition, + MAX_DEPTH_ALLOWED, +} from "@ctrlplane/validators/releases"; + +import { ReleaseConditionRender } from "./ReleaseConditionRender"; + +type ReleaseConditionDialogProps = { + condition?: ReleaseCondition; + onChange: (condition: ReleaseCondition | undefined) => void; + children: React.ReactNode; +}; + +export const ReleaseConditionDialog: 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 Release Condition + + Edit the release filter, up to a depth of {MAX_DEPTH_ALLOWED + 1}. + + + + {error && {error}} + + +
+ + + +
+ ); +}; + +// type CreateTargetViewDialogProps = { +// workspaceId: string; +// filter?: TargetCondition; +// onSubmit?: (view: schema.TargetView) => void; +// children: React.ReactNode; +// }; + +// export const CreateTargetViewDialog: React.FC = ({ +// workspaceId, +// filter, +// onSubmit, +// children, +// }) => { +// const [open, setOpen] = useState(false); +// const form = useForm({ +// schema: targetViewFormSchema, +// defaultValues: { +// name: "", +// description: "", +// filter: filter ?? defaultCondition, +// }, +// }); +// const router = useRouter(); + +// const createTargetView = api.target.view.create.useMutation(); + +// const onFormSubmit = (data: TargetViewFormSchema) => { +// createTargetView +// .mutateAsync({ +// ...data, +// workspaceId, +// }) +// .then((view) => onSubmit?.(view)) +// .then(() => form.reset()) +// .then(() => setOpen(false)) +// .then(() => router.refresh()); +// }; + +// return ( +// +// {children} +// e.stopPropagation()} +// > +// +// Create Target View +// +// Create a target view for this workspace. +// +// +// +// +// +// ); +// }; + +// type EditTargetViewDialogProps = { +// view: schema.TargetView; +// onClose?: () => void; +// onSubmit?: (view: schema.TargetView) => void; +// children: React.ReactNode; +// }; + +// export const EditTargetViewDialog: React.FC = ({ +// view, +// onClose, +// onSubmit, +// children, +// }) => { +// const [open, setOpen] = useState(false); +// const form = useForm({ +// schema: releaseViewFormSchema, +// defaultValues: { +// name: view.name, +// description: view.description ?? "", +// filter: view.filter, +// }, +// }); +// const router = useRouter(); + +// const updateTargetView = api.target.view.update.useMutation(); + +// const onFormSubmit = (data: TargetViewFormSchema) => { +// updateTargetView +// .mutateAsync({ +// id: view.id, +// data, +// }) +// .then((view) => onSubmit?.(view)) +// .then(() => setOpen(false)) +// .then(onClose) +// .then(() => router.refresh()); +// }; + +// return ( +// { +// setOpen(open); +// if (!open) onClose?.(); +// }} +// > +// {children} +// e.stopPropagation()} +// > +// +// Create Target View +// +// Create a target view for this workspace. +// +// +// +// +// +// ); +// }; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseConditionRender.tsx new file mode 100644 index 000000000..4ef148d0e --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseConditionRender.tsx @@ -0,0 +1,69 @@ +import type { ReleaseCondition } from "@ctrlplane/validators/releases"; +import React from "react"; + +import { + isComparisonCondition, + isCreatedAtCondition, + isMetadataCondition, + isVersionCondition, +} from "@ctrlplane/validators/releases"; + +import type { ReleaseConditionRenderProps } from "./release-condition-props"; +import { ComparisonConditionRender } from "./ComparisonConditionRender"; +import { CreatedAtConditionRender } from "./CreatedAtConditionRender"; +import { MetadataConditionRender } from "./MetadataConditionRender"; +import { VersionConditionRender } from "./VersionConditionRender"; + +/** + * The parent container should have min width of 1000px + * to render this component properly. + */ +export const ReleaseConditionRender: React.FC< + ReleaseConditionRenderProps +> = ({ condition, onChange, onRemove, depth = 0, className }) => { + if (isComparisonCondition(condition)) + return ( + + ); + + if (isMetadataCondition(condition)) + return ( + + ); + + if (isCreatedAtCondition(condition)) + return ( + + ); + + if (isVersionCondition(condition)) + return ( + + ); + + return null; +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/VersionConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/VersionConditionRender.tsx new file mode 100644 index 000000000..ff675ee76 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/VersionConditionRender.tsx @@ -0,0 +1,66 @@ +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/release-condition/release-condition-props.ts b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/release-condition-props.ts new file mode 100644 index 000000000..3f408c361 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/release-condition-props.ts @@ -0,0 +1,9 @@ +import type { ReleaseCondition } from "@ctrlplane/validators/releases"; + +export type ReleaseConditionRenderProps = { + condition: T; + onChange: (condition: T) => void; + onRemove?: () => void; + depth?: number; + className?: string; +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/useReleaseFilter.ts b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/useReleaseFilter.ts new file mode 100644 index 000000000..f988ac1c0 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/useReleaseFilter.ts @@ -0,0 +1,56 @@ +import type { ReleaseCondition } from "@ctrlplane/validators/releases"; +import { useCallback, useMemo } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import LZString from "lz-string"; + +export const useReleaseFilter = () => { + const urlParams = useSearchParams(); + const router = useRouter(); + + const filter = useMemo(() => { + const filterJson = urlParams.get("filter"); + if (filterJson == null) return undefined; + return JSON.parse(LZString.decompressFromEncodedURIComponent(filterJson)); + }, [urlParams]); + + const setFilter = useCallback( + (filter: ReleaseCondition | undefined) => { + if (filter == null) { + const query = new URLSearchParams(window.location.search); + query.delete("filter"); + router.replace(`?${query.toString()}`); + return; + } + + const filterJson = LZString.compressToEncodedURIComponent( + JSON.stringify(filter), + ); + const query = new URLSearchParams(window.location.search); + query.set("filter", filterJson); + router.replace(`?${query.toString()}`); + }, + [router], + ); + + // const setView = useCallback( + // (view: schema.ReleaseView) => { + // const query = new URLSearchParams(window.location.search); + // const filterJson = LZString.compressToEncodedURIComponent( + // JSON.stringify(view.filter), + // ); + // query.set("filter", filterJson); + // query.set("view", view.id); + // router.replace(`?${query.toString()}`); + // }, + // [router], + // ); + + // const removeView = () => { + // const query = new URLSearchParams(window.location.search); + // query.delete("view"); + // query.delete("filter"); + // router.replace(`?${query.toString()}`); + // }; + + return { filter, setFilter }; +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/dependencies/DependencyDiagram.tsx b/apps/webservice/src/app/[workspaceSlug]/dependencies/DependencyDiagram.tsx index 0fd1a6539..ef4257b04 100644 --- a/apps/webservice/src/app/[workspaceSlug]/dependencies/DependencyDiagram.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/dependencies/DependencyDiagram.tsx @@ -157,7 +157,7 @@ const DeploymentNode: React.FC< data.latestRelease?.id, ); const releases = api.release.list.useQuery({ deploymentId: data.id }); - const release = releases.data?.find((r) => r.id === selectedRelease); + const release = releases.data?.items.find((r) => r.id === selectedRelease); const onLayout = useOnLayout(); useEffect(() => { @@ -195,7 +195,7 @@ const DeploymentNode: React.FC< )}
diff --git a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/TableRelease.tsx b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/TableRelease.tsx deleted file mode 100644 index 6492983ac..000000000 --- a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/TableRelease.tsx +++ /dev/null @@ -1,262 +0,0 @@ -"use client"; - -import type { Deployment } from "@ctrlplane/db/schema"; -import type { ReleaseType } from "semver"; -import Link from "next/link"; -import { useParams } from "next/navigation"; -import _ from "lodash"; -import { parse, valid } from "semver"; -import { isPresent } from "ts-is-present"; - -import { cn } from "@ctrlplane/ui"; -import { Badge } from "@ctrlplane/ui/badge"; -import { Button } from "@ctrlplane/ui/button"; - -import { CreateReleaseDialog } from "~/app/[workspaceSlug]/_components/CreateRelease"; -import { useReleaseDrawer } from "~/app/[workspaceSlug]/_components/release-drawer/ReleaseDrawer"; -import { api } from "~/trpc/react"; -import { DeployButton } from "./DeployButton"; -import { Release } from "./TableCells"; - -const Icon: React.FC<{ children?: React.ReactNode; className?: string }> = ({ - children, - className, -}) => ( - - {children} - -); - -const SemverHelperButtons: React.FC<{ - deploymentId: string; - systemId: string; - version: string; -}> = ({ deploymentId, systemId, version }) => { - const inc = (releaseType: ReleaseType) => { - const sv = parse(version)!; - const hasV = version.startsWith("v"); - const versionStr = sv.inc(releaseType).version; - return hasV ? `v${versionStr}` : versionStr; - }; - return ( -
- - - - - - - - - -
- ); -}; - -export const ReleaseTable: React.FC<{ - deployment: Deployment; - environments: { - id: string; - name: string; - targets: { id: string }[]; - }[]; -}> = ({ deployment, environments }) => { - const { setReleaseId } = useReleaseDrawer(); - const { workspaceSlug, systemSlug } = useParams<{ - workspaceSlug: string; - systemSlug: string; - }>(); - const releaseJobTriggersQuery = api.job.config.byDeploymentId.useQuery( - deployment.id, - { refetchInterval: 2_000 }, - ); - - const releases = api.release.list.useQuery( - { deploymentId: deployment.id }, - { refetchInterval: 10_000 }, - ); - - const releaseJobTriggers = (releaseJobTriggersQuery.data ?? []) - .filter( - (releaseJobTrigger) => - isPresent(releaseJobTrigger.environmentId) && - isPresent(releaseJobTrigger.releaseId) && - isPresent(releaseJobTrigger.targetId), - ) - .map((releaseJobTrigger) => ({ - ...releaseJobTrigger, - environmentId: releaseJobTrigger.environmentId, - target: releaseJobTrigger.target!, - releaseId: releaseJobTrigger.releaseId, - })); - - const firstRelease = releases.data?.at(0); - const distribution = api.deployment.distributionById.useQuery(deployment.id, { - refetchInterval: 2_000, - }); - const releaseIds = releases.data?.map((r) => r.id) ?? []; - const blockedEnvByRelease = api.release.blockedEnvironments.useQuery( - releaseIds, - { enabled: releaseIds.length > 0 }, - ); - - return ( -
- - - - - {firstRelease != null && valid(firstRelease.version) && ( - - )} - - {environments.map((env, idx) => ( - - -
- {env.name} - - - {env.targets.length} - -
- -
- ))} - - - - {releases.data?.map((r, releaseIdx) => { - const blockedEnvs = blockedEnvByRelease.data?.[r.id] ?? []; - return ( - - - {environments.map((env, idx) => { - const environmentReleaseReleaseJobTriggers = - releaseJobTriggers.filter( - (t) => t.releaseId === r.id && t.environmentId === env.id, - ); - - const activeDeploymentCount = - distribution.data?.filter( - (d) => - d.release.id === r.id && - d.releaseJobTrigger.environmentId === env.id, - ).length ?? 0; - const hasTargets = env.targets.length > 0; - const hasRelease = - environmentReleaseReleaseJobTriggers.length > 0; - const hasJobAgent = deployment.jobAgentId != null; - const isBlockedByPolicyEvaluation = blockedEnvs.includes( - env.id, - ); - - const showRelease = hasRelease; - const canDeploy = - !hasRelease && - hasJobAgent && - hasTargets && - !isBlockedByPolicyEvaluation; - - return ( - - ); - })} - - ); - })} - -
setReleaseId(r.id)} - className={cn( - "sticky left-0 z-10 min-w-[250px] backdrop-blur-lg", - "items-center border-b border-l px-4 text-lg", - releaseIdx === 0 && "rounded-tl-md border-t", - releaseIdx === releases.data.length - 1 && "rounded-bl-md", - )} - > - {r.version} - 0 && "bg-neutral-400/5", - )} - > - {showRelease && ( - - )} - - {canDeploy && ( - - )} - - {!canDeploy && !hasRelease && ( -
- {isBlockedByPolicyEvaluation - ? "Blocked by policy" - : hasJobAgent - ? "No targets" - : "No job agent"} -
- )} -
-
- ); -}; diff --git a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/DeploymentPageContent.tsx b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/DeploymentPageContent.tsx index ae4fce7ce..b0a360fc1 100644 --- a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/DeploymentPageContent.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/DeploymentPageContent.tsx @@ -25,6 +25,9 @@ import { TableRow, } from "@ctrlplane/ui/table"; +import { ReleaseConditionBadge } from "~/app/[workspaceSlug]/_components/release-condition/ReleaseConditionBadge"; +import { ReleaseConditionDialog } from "~/app/[workspaceSlug]/_components/release-condition/ReleaseConditionDialog"; +import { useReleaseFilter } from "~/app/[workspaceSlug]/_components/release-condition/useReleaseFilter"; import { api } from "~/trpc/react"; import { DeployButton } from "../DeployButton"; import { Release } from "../TableCells"; @@ -43,6 +46,8 @@ export const DeploymentPageContent: React.FC = ({ deployment, environments, }) => { + const { filter, setFilter } = useReleaseFilter(); + const { workspaceSlug, systemSlug } = useParams<{ workspaceSlug: string; systemSlug: string; @@ -54,7 +59,7 @@ export const DeploymentPageContent: React.FC = ({ ); const releases = api.release.list.useQuery( - { deploymentId: deployment.id }, + { deploymentId: deployment.id, filter, limit: 30 }, { refetchInterval: 10_000 }, ); @@ -77,7 +82,7 @@ export const DeploymentPageContent: React.FC = ({ const distribution = api.deployment.distributionById.useQuery(deployment.id, { refetchInterval: 2_000, }); - const releaseIds = releases.data?.map((r) => r.id) ?? []; + const releaseIds = releases.data?.items.map((r) => r.id) ?? []; const blockedEnvByRelease = api.release.blockedEnvironments.useQuery( releaseIds, { enabled: releaseIds.length > 0 }, @@ -85,6 +90,7 @@ export const DeploymentPageContent: React.FC = ({ const loading = releases.isLoading || releaseJobTriggersQuery.isLoading; + // TODO: the deployment totals should be for all deployments return (
@@ -112,7 +118,7 @@ export const DeploymentPageContent: React.FC = ({
Releases - {releases.data?.length ?? "-"} + {releases.data?.total ?? "-"}
@@ -132,15 +138,19 @@ export const DeploymentPageContent: React.FC = ({
-
- -
+ +
+ + + {filter != null && } +
+
@@ -184,12 +194,13 @@ export const DeploymentPageContent: React.FC = ({ - {releases.data.map((release, releaseIdx) => ( + {releases.data.items.map((release, releaseIdx) => (
@@ -233,7 +244,7 @@ export const DeploymentPageContent: React.FC = ({ diff --git a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/DistroBarChart.tsx b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/DistroBarChart.tsx index db4ff88e2..1beed99ce 100644 --- a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/DistroBarChart.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/DistroBarChart.tsx @@ -17,7 +17,7 @@ export const DistroBarChart: React.FC<{ refetchInterval: 2_000, }); - const distro = _.chain(releases.data ?? []) + const distro = _.chain(releases.data?.items ?? []) .map((r) => ({ version: r.version, count: (distribution.data ?? []).filter((d) => d.release.id === r.id) diff --git a/packages/api/src/release-filter.ts b/packages/api/src/release-filter.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/api/src/router/release.ts b/packages/api/src/router/release.ts index 9e3f32080..340c456db 100644 --- a/packages/api/src/router/release.ts +++ b/packages/api/src/router/release.ts @@ -7,6 +7,7 @@ import { z } from "zod"; import { and, + count, desc, eq, inArray, @@ -25,6 +26,7 @@ import { release, releaseDependency, releaseJobTrigger, + releaseMatchesCondition, releaseMetadata, target, } from "@ctrlplane/db/schema"; @@ -39,6 +41,7 @@ import { isPassingReleaseStringCheckPolicy, } from "@ctrlplane/job-dispatch"; import { Permission } from "@ctrlplane/validators/auth"; +import { releaseCondition } from "@ctrlplane/validators/releases"; import { createTRPCRouter, protectedProcedure } from "../trpc"; @@ -53,19 +56,38 @@ export const releaseRouter = createTRPCRouter({ .input( z.object({ deploymentId: z.string(), + filter: releaseCondition.optional(), limit: z.number().optional(), offset: z.number().optional(), }), ) - .query(({ ctx, input }) => - ctx.db + .query(({ ctx, input }) => { + console.log(input.filter); + + const deploymentIdCheck = eq(release.deploymentId, input.deploymentId); + const releaseConditionCheck = releaseMatchesCondition( + ctx.db, + input.filter, + ); + const checks = [deploymentIdCheck, releaseConditionCheck].filter( + isPresent, + ); + + const items = ctx.db .select() .from(release) .leftJoin( releaseDependency, eq(release.id, releaseDependency.releaseId), ) - .where(eq(release.deploymentId, input.deploymentId)) + .where( + and( + ...[ + eq(release.deploymentId, input.deploymentId), + releaseMatchesCondition(ctx.db, input.filter), + ].filter(isPresent), + ), + ) .orderBy(desc(release.createdAt)) .limit(input.limit ?? 1000) .offset(input.offset ?? 0) @@ -79,8 +101,22 @@ export const releaseRouter = createTRPCRouter({ .filter(isPresent), })) .value(), - ), - ), + ); + + const total = ctx.db + .select({ + count: count().mapWith(Number), + }) + .from(release) + .where(and(...checks)) + .then(takeFirst) + .then((t) => t.count); + + return Promise.all([items, total]).then(([items, total]) => ({ + items, + total, + })); + }), byId: protectedProcedure .meta({ @@ -378,4 +414,24 @@ export const releaseRouter = createTRPCRouter({ {} as Record, ); }), + + metadataKeys: protectedProcedure + .meta({ + authorizationCheck: ({ canUser, input }) => + canUser + .perform(Permission.ReleaseGet) + .on({ type: "deployment", id: input }), + }) + .input(z.string().uuid()) + .query(async ({ input }) => { + const keys = await db + .selectDistinct({ key: releaseMetadata.key }) + .from(release) + .innerJoin(releaseMetadata, eq(releaseMetadata.releaseId, release.id)) + .innerJoin(deployment, eq(release.deploymentId, deployment.id)) + .where(eq(deployment.id, input)) + .then((r) => r.map((row) => row.key)); + + return keys; + }), }); diff --git a/packages/db/src/schema/release.ts b/packages/db/src/schema/release.ts index 71c4c83db..88fdafdd4 100644 --- a/packages/db/src/schema/release.ts +++ b/packages/db/src/schema/release.ts @@ -1,4 +1,24 @@ -import type { InferInsertModel, InferSelectModel } from "drizzle-orm"; +import type { + CreatedAtCondition, + MetadataCondition, + ReleaseCondition, + VersionCondition, +} from "@ctrlplane/validators/releases"; +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 { jsonb, pgEnum, @@ -11,6 +31,12 @@ import { import { createInsertSchema } from "drizzle-zod"; import { z } from "zod"; +import { + ReleaseFilterType, + ReleaseOperator, +} from "@ctrlplane/validators/releases"; + +import type { Tx } from "../common.js"; import { user } from "./auth.js"; import { deployment } from "./deployment.js"; import { environment } from "./environment.js"; @@ -138,3 +164,104 @@ export type ReleaseJobTriggerType = ReleaseJobTrigger["type"]; export type ReleaseJobTriggerInsert = InferInsertModel< typeof releaseJobTrigger >; + +const buildMetadataCondition = (tx: Tx, cond: MetadataCondition): SQL => { + if (cond.operator === "null") + return notExists( + tx + .select() + .from(releaseMetadata) + .where( + and( + eq(releaseMetadata.releaseId, release.id), + eq(releaseMetadata.key, cond.key), + ), + ), + ); + + if (cond.operator === "regex") + return exists( + tx + .select() + .from(releaseMetadata) + .where( + and( + eq(releaseMetadata.releaseId, release.id), + eq(releaseMetadata.key, cond.key), + sql`${releaseMetadata.value} ~ ${cond.value}`, + ), + ), + ); + + if (cond.operator === "like") + return exists( + tx + .select() + .from(releaseMetadata) + .where( + and( + eq(releaseMetadata.releaseId, release.id), + eq(releaseMetadata.key, cond.key), + like(releaseMetadata.value, cond.value), + ), + ), + ); + + return exists( + tx + .select() + .from(releaseMetadata) + .where( + and( + eq(releaseMetadata.releaseId, release.id), + eq(releaseMetadata.key, cond.key), + eq(releaseMetadata.value, cond.value), + ), + ), + ); +}; + +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) + return lte(release.createdAt, date); + return gte(release.createdAt, date); +}; + +const buildVersionCondition = (cond: VersionCondition): SQL => { + if (cond.operator === ReleaseOperator.Equals) + return eq(release.version, cond.value); + if (cond.operator === ReleaseOperator.Like) + return like(release.version, cond.value); + return sql`${release.version} ~ ${cond.value}`; +}; + +const buildCondition = (tx: Tx, cond: ReleaseCondition): SQL => { + if (cond.type === ReleaseFilterType.Metadata) + return buildMetadataCondition(tx, cond); + if (cond.type === ReleaseFilterType.CreatedAt) + return buildCreatedAtCondition(cond); + if (cond.type === ReleaseFilterType.Version) + return buildVersionCondition(cond); + + if (cond.conditions.length === 0 && cond.not) return sql`FALSE`; + if (cond.conditions.length === 0) return sql`TRUE`; + + const subCon = cond.conditions.map((c) => buildCondition(tx, c)); + const con = + cond.operator === ReleaseOperator.And ? and(...subCon)! : or(...subCon)!; + return cond.not ? not(con) : con; +}; + +export function releaseMatchesCondition( + tx: Tx, + condition?: ReleaseCondition, +): SQL | undefined { + return condition == null || Object.keys(condition).length === 0 + ? undefined + : buildCondition(tx, condition); +} diff --git a/packages/ui/src/date-time-picker/calendar.tsx b/packages/ui/src/date-time-picker/calendar.tsx index d0c88edab..09fda1bb7 100644 --- a/packages/ui/src/date-time-picker/calendar.tsx +++ b/packages/ui/src/date-time-picker/calendar.tsx @@ -51,13 +51,13 @@ function Calendar(props: CalendarProps) { return (
-
+
- +
{!!state.hasTime && ( diff --git a/packages/validators/package.json b/packages/validators/package.json index 8b5dbc11f..a07927367 100644 --- a/packages/validators/package.json +++ b/packages/validators/package.json @@ -12,6 +12,10 @@ "types": "./src/targets/index.ts", "default": "./dist/targets/index.js" }, + "./releases": { + "types": "./src/releases/index.ts", + "default": "./dist/releases/index.js" + }, "./events": { "types": "./src/events/index.ts", "default": "./dist/events/index.js" diff --git a/packages/validators/src/releases/conditions/comparison-condition.ts b/packages/validators/src/releases/conditions/comparison-condition.ts new file mode 100644 index 000000000..337b32c54 --- /dev/null +++ b/packages/validators/src/releases/conditions/comparison-condition.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; + +import type { CreatedAtCondition } from "./created-at-condition.js"; +import type { MetadataCondition } from "./metadata-condition.js"; +import type { VersionCondition } from "./version-condition.js"; +import { createdAtCondition } from "./created-at-condition.js"; +import { metadataCondition } from "./metadata-condition.js"; +import { versionCondition } from "./version-condition.js"; + +export const comparisonCondition: z.ZodType = z.lazy(() => + z.object({ + type: z.literal("comparison"), + operator: z.literal("or").or(z.literal("and")), + not: z.boolean().optional().default(false), + conditions: z.array( + z.union([ + metadataCondition, + comparisonCondition, + versionCondition, + createdAtCondition, + ]), + ), + }), +); + +export type ComparisonCondition = { + type: "comparison"; + operator: "and" | "or"; + not?: boolean; + conditions: Array< + | ComparisonCondition + | MetadataCondition + | VersionCondition + | CreatedAtCondition + >; +}; diff --git a/packages/validators/src/releases/conditions/created-at-condition.ts b/packages/validators/src/releases/conditions/created-at-condition.ts new file mode 100644 index 000000000..92a35b20c --- /dev/null +++ b/packages/validators/src/releases/conditions/created-at-condition.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +export const createdAtCondition = z.object({ + type: z.literal("created-at"), + operator: z + .literal("before") + .or(z.literal("after")) + .or(z.literal("before-or-on")) + .or(z.literal("after-or-on")), + value: z.string().refine((v) => !isNaN(new Date(v).getTime()), { + message: "Invalid date", + }), +}); + +export type CreatedAtCondition = z.infer; diff --git a/packages/validators/src/releases/conditions/index.ts b/packages/validators/src/releases/conditions/index.ts new file mode 100644 index 000000000..df1a12ac9 --- /dev/null +++ b/packages/validators/src/releases/conditions/index.ts @@ -0,0 +1,5 @@ +export * from "./created-at-condition.js"; +export * from "./metadata-condition.js"; +export * from "./version-condition.js"; +export * from "./comparison-condition.js"; +export * from "./release-condition.js"; diff --git a/packages/validators/src/releases/conditions/metadata-condition.ts b/packages/validators/src/releases/conditions/metadata-condition.ts new file mode 100644 index 000000000..de33fc965 --- /dev/null +++ b/packages/validators/src/releases/conditions/metadata-condition.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; + +export const nullCondition = z.object({ + type: z.literal("metadata"), + key: z.string().min(1), + value: z.string().optional(), + operator: z.literal("null"), +}); + +export type NullCondition = z.infer; + +export const equalsCondition = z.object({ + type: z.literal("metadata"), + key: z.string().min(1), + value: z.string().min(1), + operator: z.literal("equals").optional(), +}); + +export type EqualCondition = z.infer; + +export const regexCondition = z.object({ + type: z.literal("metadata"), + key: z.string().min(1), + value: z.string().min(1), + operator: z.literal("regex"), +}); + +export type RegexCondition = z.infer; + +export const likeCondition = z.object({ + type: z.literal("metadata"), + key: z.string().min(1), + value: z.string().min(1), + operator: z.literal("like"), +}); + +export type LikeCondition = z.infer; + +export const metadataCondition = z.union([ + likeCondition, + regexCondition, + equalsCondition, + nullCondition, +]); + +export type MetadataCondition = z.infer; + +export enum ReservedMetadataKey { + ExternalId = "ctrlplane/external-id", + Links = "ctrlplane/links", + ParentTargetIdentifier = "ctrlplane/parent-target-identifier", + KubernetesVersion = "kubernetes/version", + KubernetesFlavor = "kubernetes/flavor", +} diff --git a/packages/validators/src/releases/conditions/release-condition.ts b/packages/validators/src/releases/conditions/release-condition.ts new file mode 100644 index 000000000..02c354125 --- /dev/null +++ b/packages/validators/src/releases/conditions/release-condition.ts @@ -0,0 +1,103 @@ +import { z } from "zod"; + +import type { ComparisonCondition } from "./comparison-condition.js"; +import type { CreatedAtCondition } from "./created-at-condition.js"; +import type { MetadataCondition } from "./metadata-condition.js"; +import type { VersionCondition } from "./version-condition.js"; +import { comparisonCondition } from "./comparison-condition.js"; +import { createdAtCondition } from "./created-at-condition.js"; +import { metadataCondition } from "./metadata-condition.js"; +import { versionCondition } from "./version-condition.js"; + +export type ReleaseCondition = + | ComparisonCondition + | MetadataCondition + | VersionCondition + | CreatedAtCondition; + +export const releaseCondition = z.union([ + comparisonCondition, + metadataCondition, + versionCondition, + createdAtCondition, +]); + +export enum ReleaseOperator { + Equals = "equals", + Like = "like", + Regex = "regex", + Null = "null", + And = "and", + Or = "or", + Before = "before", + After = "after", + BeforeOrOn = "before-or-on", + AfterOrOn = "after-or-on", +} + +export enum ReleaseFilterType { + Metadata = "metadata", + Version = "version", + Comparison = "comparison", + CreatedAt = "created-at", +} + +export const defaultCondition: ReleaseCondition = { + type: ReleaseFilterType.Comparison, + operator: ReleaseOperator.And, + not: false, + conditions: [], +}; + +export const isComparisonCondition = ( + condition: ReleaseCondition, +): condition is ComparisonCondition => + condition.type === ReleaseFilterType.Comparison; + +export const MAX_DEPTH_ALLOWED = 2; // 0 indexed + +// Check if converting to a comparison condition will exceed the max depth +// including any nested conditions +export const doesConvertingToComparisonRespectMaxDepth = ( + depth: number, + condition: ReleaseCondition, +): 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 isMetadataCondition = ( + condition: ReleaseCondition, +): condition is MetadataCondition => + condition.type === ReleaseFilterType.Metadata; + +export const isVersionCondition = ( + condition: ReleaseCondition, +): condition is VersionCondition => + condition.type === ReleaseFilterType.Version; + +export const isCreatedAtCondition = ( + condition: ReleaseCondition, +): condition is CreatedAtCondition => + condition.type === ReleaseFilterType.CreatedAt; + +export const isValidReleaseCondition = ( + condition: ReleaseCondition, +): boolean => { + if (isComparisonCondition(condition)) + return condition.conditions.every((c) => isValidReleaseCondition(c)); + if (isVersionCondition(condition)) return condition.value.length > 0; + if (isCreatedAtCondition(condition)) return true; + if (isMetadataCondition(condition)) { + if (condition.operator === ReleaseOperator.Null) + return condition.value == null && condition.key.length > 0; + return condition.value.length > 0 && condition.key.length > 0; + } + return false; +}; diff --git a/packages/validators/src/releases/conditions/version-condition.ts b/packages/validators/src/releases/conditions/version-condition.ts new file mode 100644 index 000000000..9884f720f --- /dev/null +++ b/packages/validators/src/releases/conditions/version-condition.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const versionCondition = z.object({ + type: z.literal("version"), + operator: z.literal("like").or(z.literal("regex")).or(z.literal("equals")), + value: z.string().min(1), +}); + +export type VersionCondition = z.infer; diff --git a/packages/validators/src/releases/index.ts b/packages/validators/src/releases/index.ts new file mode 100644 index 000000000..9b3926bf1 --- /dev/null +++ b/packages/validators/src/releases/index.ts @@ -0,0 +1 @@ +export * from "./conditions/index.js"; From 039a07eba6acbf2972cd7f429e163ac653c1b720 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Fri, 4 Oct 2024 15:05:56 -0700 Subject: [PATCH 2/3] changes --- .../ComparisonConditionRender.tsx | 33 ---------- .../[systemSlug]/deployments/TableCells.tsx | 2 +- .../DeploymentPageContent.tsx | 61 ++++++++++++------- packages/api/src/router/release.ts | 2 - 4 files changed, 40 insertions(+), 58 deletions(-) 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 dd1e433ba..076aaf3d3 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ComparisonConditionRender.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ComparisonConditionRender.tsx @@ -295,39 +295,6 @@ export const ComparisonConditionRender: React.FC< > Version - {/* - addCondition({ - type: ReleaseFilterType.Kind, - operator: ReleaseOperator.Equals, - value: "", - }) - } - > - Kind - - - addCondition({ - type: TargetFilterType.Name, - operator: TargetOperator.Like, - value: "", - }) - } - > - Name - - - addCondition({ - type: TargetFilterType.Provider, - operator: TargetOperator.Equals, - value: "", - }) - } - > - Provider - */} {depth < 2 && ( diff --git a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/TableCells.tsx b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/TableCells.tsx index beac4cbe5..e07b5bc29 100644 --- a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/TableCells.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/TableCells.tsx @@ -167,7 +167,7 @@ export const Release: React.FC<{ ); return ( -
+
= ({ environments, }) => { const { filter, setFilter } = useReleaseFilter(); + const { setReleaseId } = useReleaseDrawer(); const { workspaceSlug, systemSlug } = useParams<{ workspaceSlug: string; @@ -58,6 +57,11 @@ export const DeploymentPageContent: React.FC = ({ { refetchInterval: 2_000 }, ); + const releasesAll = api.release.list.useQuery( + { deploymentId: deployment.id, limit: 0 }, + { refetchInterval: 10_000 }, + ); + const releases = api.release.list.useQuery( { deploymentId: deployment.id, filter, limit: 30 }, { refetchInterval: 10_000 }, @@ -90,7 +94,6 @@ export const DeploymentPageContent: React.FC = ({ const loading = releases.isLoading || releaseJobTriggersQuery.isLoading; - // TODO: the deployment totals should be for all deployments return (
@@ -118,7 +121,7 @@ export const DeploymentPageContent: React.FC = ({
Releases - {releases.data?.total ?? "-"} + {releasesAll.data?.total ?? "-"}
@@ -151,6 +154,15 @@ export const DeploymentPageContent: React.FC = ({ {filter != null && }
+
+ Total: + + {releases.data?.total ?? "-"} + +
@@ -171,16 +183,13 @@ export const DeploymentPageContent: React.FC = ({ -
+
Version
{environments.map((env) => ( - -
+ +
{env.name} = ({ {releases.data.items.map((release, releaseIdx) => ( - + setReleaseId(release.id)} + > -
-
- - -
- {release.version} +
+ {release.version}{" "} + + {formatDistanceToNowStrict(release.createdAt, { + addSuffix: true, + })} +
{environments.map((env) => { @@ -243,7 +260,7 @@ export const DeploymentPageContent: React.FC = ({ return ( { - console.log(input.filter); - const deploymentIdCheck = eq(release.deploymentId, input.deploymentId); const releaseConditionCheck = releaseMatchesCondition( ctx.db, From 969de0116b3ab8687ad9d930ef8d71523dbf3c4a Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Fri, 4 Oct 2024 15:08:37 -0700 Subject: [PATCH 3/3] remove comments --- .../ReleaseConditionBadge.tsx | 1 - .../ReleaseConditionDialog.tsx | 120 ------------------ .../release-condition/useReleaseFilter.ts | 20 --- 3 files changed, 141 deletions(-) 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 ba90db6f0..8f571d3b3 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseConditionBadge.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseConditionBadge.tsx @@ -46,7 +46,6 @@ const ConditionBadge: React.FC<{ }> = ({ children }) => ( {children} diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseConditionDialog.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseConditionDialog.tsx index d98d1ac12..c21e08503 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseConditionDialog.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/ReleaseConditionDialog.tsx @@ -85,123 +85,3 @@ export const ReleaseConditionDialog: React.FC = ({ ); }; - -// type CreateTargetViewDialogProps = { -// workspaceId: string; -// filter?: TargetCondition; -// onSubmit?: (view: schema.TargetView) => void; -// children: React.ReactNode; -// }; - -// export const CreateTargetViewDialog: React.FC = ({ -// workspaceId, -// filter, -// onSubmit, -// children, -// }) => { -// const [open, setOpen] = useState(false); -// const form = useForm({ -// schema: targetViewFormSchema, -// defaultValues: { -// name: "", -// description: "", -// filter: filter ?? defaultCondition, -// }, -// }); -// const router = useRouter(); - -// const createTargetView = api.target.view.create.useMutation(); - -// const onFormSubmit = (data: TargetViewFormSchema) => { -// createTargetView -// .mutateAsync({ -// ...data, -// workspaceId, -// }) -// .then((view) => onSubmit?.(view)) -// .then(() => form.reset()) -// .then(() => setOpen(false)) -// .then(() => router.refresh()); -// }; - -// return ( -// -// {children} -// e.stopPropagation()} -// > -// -// Create Target View -// -// Create a target view for this workspace. -// -// -// -// -// -// ); -// }; - -// type EditTargetViewDialogProps = { -// view: schema.TargetView; -// onClose?: () => void; -// onSubmit?: (view: schema.TargetView) => void; -// children: React.ReactNode; -// }; - -// export const EditTargetViewDialog: React.FC = ({ -// view, -// onClose, -// onSubmit, -// children, -// }) => { -// const [open, setOpen] = useState(false); -// const form = useForm({ -// schema: releaseViewFormSchema, -// defaultValues: { -// name: view.name, -// description: view.description ?? "", -// filter: view.filter, -// }, -// }); -// const router = useRouter(); - -// const updateTargetView = api.target.view.update.useMutation(); - -// const onFormSubmit = (data: TargetViewFormSchema) => { -// updateTargetView -// .mutateAsync({ -// id: view.id, -// data, -// }) -// .then((view) => onSubmit?.(view)) -// .then(() => setOpen(false)) -// .then(onClose) -// .then(() => router.refresh()); -// }; - -// return ( -// { -// setOpen(open); -// if (!open) onClose?.(); -// }} -// > -// {children} -// e.stopPropagation()} -// > -// -// Create Target View -// -// Create a target view for this workspace. -// -// -// -// -// -// ); -// }; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/useReleaseFilter.ts b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/useReleaseFilter.ts index f988ac1c0..60c3b9bc0 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/useReleaseFilter.ts +++ b/apps/webservice/src/app/[workspaceSlug]/_components/release-condition/useReleaseFilter.ts @@ -32,25 +32,5 @@ export const useReleaseFilter = () => { [router], ); - // const setView = useCallback( - // (view: schema.ReleaseView) => { - // const query = new URLSearchParams(window.location.search); - // const filterJson = LZString.compressToEncodedURIComponent( - // JSON.stringify(view.filter), - // ); - // query.set("filter", filterJson); - // query.set("view", view.id); - // router.replace(`?${query.toString()}`); - // }, - // [router], - // ); - - // const removeView = () => { - // const query = new URLSearchParams(window.location.search); - // query.delete("view"); - // query.delete("filter"); - // router.replace(`?${query.toString()}`); - // }; - return { filter, setFilter }; };