diff --git a/apps/web/app/routes/ws/deployments/_components/environmentversiondecisions/policy-skip/CreateSkipForm.tsx b/apps/web/app/routes/ws/deployments/_components/environmentversiondecisions/policy-skip/CreateSkipForm.tsx index 403ed921a..fa600f71e 100644 --- a/apps/web/app/routes/ws/deployments/_components/environmentversiondecisions/policy-skip/CreateSkipForm.tsx +++ b/apps/web/app/routes/ws/deployments/_components/environmentversiondecisions/policy-skip/CreateSkipForm.tsx @@ -1,20 +1,9 @@ import type { WorkspaceEngine } from "@ctrlplane/workspace-engine-sdk"; -import type { UseFormReturn } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; +import { useState } from "react"; import { toast } from "sonner"; -import { z } from "zod"; import { trpc } from "~/api/trpc"; import { Button } from "~/components/ui/button"; -import { DateTimePicker } from "~/components/ui/datetime-picker"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, -} from "~/components/ui/form"; import { Select, SelectContent, @@ -22,134 +11,96 @@ import { SelectTrigger, } from "~/components/ui/select"; import { useWorkspace } from "~/components/WorkspaceProvider"; +import { ExpirySelect } from "../../release-targets/ExpirySelect"; +import { expiryOptionsForRule } from "../../release-targets/skip-expiry"; import { getRuleDisplay } from "./utils"; -const formSchema = z.object({ - id: z.string(), - expiresAt: z.date().optional(), -}); -type FormSchema = z.infer; - -function useSkipForm(environmentId: string, versionId: string) { - const { workspace } = useWorkspace(); - const form = useForm({ - resolver: zodResolver(formSchema), - }); - - const utils = trpc.useUtils(); - - const createSkipMutation = - trpc.policySkips.createForEnvAndVersion.useMutation(); - const onSubmit = form.handleSubmit((data) => { - createSkipMutation - .mutateAsync({ - workspaceId: workspace.id, - environmentId, - versionId, - ruleId: data.id, - expiresAt: data.expiresAt, - }) - .then(() => toast.success("Skip added")) - .then(() => form.reset()) - .then(() => - utils.policySkips.forEnvAndVersion.invalidate({ - environmentId, - versionId, - }), - ); - }); - - return { form, onSubmit, isPending: createSkipMutation.isPending }; -} - -function RuleSelect({ - rules, - form, -}: { +type CreateSkipFormProps = { rules: WorkspaceEngine["schemas"]["PolicyRule"][]; - form: UseFormReturn; -}) { - const selectedRuleId = form.watch("id"); - const selectedRule = rules.find((rule) => rule.id === selectedRuleId); - - return ( - ( - - Rule - - - - - )} - /> - ); -} - -function ExpiresAtSelect({ form }: { form: UseFormReturn }) { - return ( - ( - - Expires At (optional) - - - - - )} - /> - ); -} + environmentId: string; + versionId: string; +}; export function CreateSkipForm({ rules, environmentId, versionId, -}: { - rules: WorkspaceEngine["schemas"]["PolicyRule"][]; - environmentId: string; - versionId: string; -}) { - const { form, onSubmit, isPending } = useSkipForm(environmentId, versionId); +}: CreateSkipFormProps) { + const { workspace } = useWorkspace(); + const utils = trpc.useUtils(); + const [now] = useState(() => new Date()); + const [ruleId, setRuleId] = useState(undefined); + const [selectedId, setSelectedId] = useState(undefined); + + const { data: evaluations = [] } = trpc.deploymentVersions.evaulate.useQuery( + { versionId, environmentId }, + { enabled: ruleId != null }, + ); + const options = expiryOptionsForRule(evaluations, ruleId, now); + const selected = options.find((o) => o.id === selectedId) ?? options[0]; + const expiresAt = selected?.value ?? undefined; + + const selectedRule = rules.find((rule) => rule.id === ruleId); + const createSkip = trpc.policySkips.createForEnvAndVersion.useMutation({ + onSuccess: () => { + toast.success("Skip added"); + setRuleId(undefined); + setSelectedId(undefined); + utils.policySkips.forEnvAndVersion.invalidate({ environmentId, versionId }); + }, + }); - const selectedRuleId = form.watch("id"); + const onAdd = () => { + if (ruleId == null) return; + createSkip.mutate({ + workspaceId: workspace.id, + environmentId, + versionId, + ruleId, + expiresAt, + }); + }; return ( -
- -

Add new skip

-
- - - +
+ ); } diff --git a/apps/web/app/routes/ws/deployments/_components/release-targets/ExpirySelect.tsx b/apps/web/app/routes/ws/deployments/_components/release-targets/ExpirySelect.tsx new file mode 100644 index 000000000..e919924a6 --- /dev/null +++ b/apps/web/app/routes/ws/deployments/_components/release-targets/ExpirySelect.tsx @@ -0,0 +1,50 @@ +import { formatDistanceToNowStrict } from "date-fns"; +import { TriangleAlert } from "lucide-react"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from "~/components/ui/select"; +import type { ExpiryOption } from "./skip-expiry"; + +export type ExpirySelectProps = { + options: ExpiryOption[]; + selectedId: string | undefined; + onChange: (id: string) => void; +}; + +export function ExpirySelect({ options, selectedId, onChange }: ExpirySelectProps) { + const selected = options.find((o) => o.id === selectedId) ?? options[0]; + + return ( +
+ Expires + + {selected != null && selected.value != null && ( +

+ Expires in {formatDistanceToNowStrict(selected.value)} +

+ )} + {selected != null && selected.value == null && ( +

+ + Not recommended — permanent skips can have unintended consequences down + the line. +

+ )} +
+ ); +} diff --git a/apps/web/app/routes/ws/deployments/_components/release-targets/PolicySkipActions.tsx b/apps/web/app/routes/ws/deployments/_components/release-targets/PolicySkipActions.tsx index 9b2a88813..abb1f15a1 100644 --- a/apps/web/app/routes/ws/deployments/_components/release-targets/PolicySkipActions.tsx +++ b/apps/web/app/routes/ws/deployments/_components/release-targets/PolicySkipActions.tsx @@ -5,13 +5,14 @@ import { toast } from "sonner"; import { trpc } from "~/api/trpc"; import { Button } from "~/components/ui/button"; -import { DateTimePicker } from "~/components/ui/datetime-picker"; import { Popover, PopoverContent, PopoverTrigger, } from "~/components/ui/popover"; import { useWorkspace } from "~/components/WorkspaceProvider"; +import { ExpirySelect } from "./ExpirySelect"; +import { expiryOptionsForRule } from "./skip-expiry"; function RemoveSkipButton({ skipId }: { skipId: string }) { const { workspace } = useWorkspace(); @@ -87,11 +88,55 @@ type SkipTarget = { ruleId: string; }; +function SkipExpiryForm({ + target, + isPending, + onSubmit, +}: { + target: SkipTarget; + isPending: boolean; + onSubmit: (expiresAt: Date | undefined) => void; +}) { + const [now] = useState(() => new Date()); + const [selectedId, setSelectedId] = useState(undefined); + + const { data: evaluations = [] } = trpc.deploymentVersions.evaulate.useQuery({ + versionId: target.versionId, + environmentId: target.environmentId, + }); + const options = expiryOptionsForRule(evaluations, target.ruleId, now); + const selected = options.find((o) => o.id === selectedId) ?? options[0]; + const expiresAt = selected?.value ?? undefined; + + return ( +
+
+

Skip this rule

+

+ Bypass this policy rule for this release target only. +

+
+ + +
+ ); +} + export function SkipRuleButton({ target }: { target: SkipTarget }) { const { workspace } = useWorkspace(); const utils = trpc.useUtils(); const [open, setOpen] = useState(false); - const [expiresAt, setExpiresAt] = useState(undefined); const createSkip = trpc.policySkips.createForTarget.useMutation({ onSuccess: () => { @@ -99,7 +144,6 @@ export function SkipRuleButton({ target }: { target: SkipTarget }) { utils.policySkips.forTarget.invalidate(); utils.releaseTargets.evaluations.invalidate(); setOpen(false); - setExpiresAt(undefined); }, onError: (error) => toast.error("Failed to skip rule", { description: error.message }), @@ -117,26 +161,11 @@ export function SkipRuleButton({ target }: { target: SkipTarget }) { Skip for this target - -
-

Skip this rule

-

- Bypass this policy rule for this release target only. -

-
-
- Expires at (optional) - -
- + />
); diff --git a/apps/web/app/routes/ws/deployments/_components/release-targets/skip-expiry.ts b/apps/web/app/routes/ws/deployments/_components/release-targets/skip-expiry.ts new file mode 100644 index 000000000..4a5d7a09c --- /dev/null +++ b/apps/web/app/routes/ws/deployments/_components/release-targets/skip-expiry.ts @@ -0,0 +1,98 @@ +import { addHours } from "date-fns"; + +export type ExpiryOption = { id: string; label: string; value: Date | null }; + +export type RuleEvaluation = { + ruleId: string; + details: unknown; + nextEvaluationAt?: Date | string | null; +}; + +type RuleBasedExpiry = { label: string; value: Date }; + +function toDate(value: unknown): Date | null { + if (value == null) return null; + const date = value instanceof Date ? value : new Date(value as string | number); + return isNaN(date.getTime()) ? null : date; +} + +function ruleBasedExpiry( + details: unknown, + nextEvaluationAt: Date | null, + now: Date, +): RuleBasedExpiry | null { + const d = (details ?? {}) as { + target_rollout_position?: unknown; + target_rollout_time?: unknown; + next_window_start?: unknown; + next_deployment_time?: unknown; + }; + + const isGradualRollout = + d.target_rollout_position != null || d.target_rollout_time != null; + if (isGradualRollout) { + const rolloutTime = toDate(d.target_rollout_time); + return rolloutTime != null && rolloutTime > now + ? { label: "Until rollout completes", value: rolloutTime } + : null; + } + + const windowOpen = toDate(d.next_window_start); + if (windowOpen != null && windowOpen > now) + return { label: "Until deploy window opens", value: windowOpen }; + + const cooldownEnd = toDate(d.next_deployment_time); + if (cooldownEnd != null && cooldownEnd > now) + return { label: "Until cooldown ends", value: cooldownEnd }; + + if (nextEvaluationAt != null && nextEvaluationAt > now) + return { label: "Until this rule passes", value: nextEvaluationAt }; + + return null; +} + +// `evaulate` returns evaluations across every resource for the rule with no +// guaranteed ordering, and each resource clears the rule at a different time. +// Take the latest so a single skip covers the whole set. +function latestRuleBasedExpiry( + evaluations: RuleEvaluation[], + now: Date, +): RuleBasedExpiry | null { + let latest: RuleBasedExpiry | null = null; + for (const evaluation of evaluations) { + const candidate = ruleBasedExpiry( + evaluation.details, + toDate(evaluation.nextEvaluationAt), + now, + ); + if (candidate == null) continue; + if (latest == null || candidate.value > latest.value) latest = candidate; + } + return latest; +} + +export function expiryOptionsForRule( + evaluations: RuleEvaluation[], + ruleId: string | undefined, + now: Date, +): ExpiryOption[] { + const ruleBased = + ruleId == null + ? null + : latestRuleBasedExpiry( + evaluations.filter((evaluation) => evaluation.ruleId === ruleId), + now, + ); + + const options: ExpiryOption[] = []; + if (ruleBased != null) + options.push({ id: "rule", label: ruleBased.label, value: ruleBased.value }); + for (const hours of [6, 12, 24]) + options.push({ + id: `${hours}h`, + label: `${hours} hours`, + value: addHours(now, hours), + }); + options.push({ id: "none", label: "No expiration", value: null }); + return options; +}