From 520261a961e38472e5a9b9de7dcf33231f21e71f Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Wed, 10 Jun 2026 00:00:28 -0400 Subject: [PATCH 1/2] feat: suggest bounded expirations for policy skips in ui --- .../policy-skip/CreateSkipForm.tsx | 207 +++++++----------- .../release-targets/ExpirySelect.tsx | 50 +++++ .../release-targets/PolicySkipActions.tsx | 79 ++++--- .../release-targets/skip-expiry.ts | 115 ++++++++++ 4 files changed, 297 insertions(+), 154 deletions(-) create mode 100644 apps/web/app/routes/ws/deployments/_components/release-targets/ExpirySelect.tsx create mode 100644 apps/web/app/routes/ws/deployments/_components/release-targets/skip-expiry.ts 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..c5f8b7582 --- /dev/null +++ b/apps/web/app/routes/ws/deployments/_components/release-targets/skip-expiry.ts @@ -0,0 +1,115 @@ +import { addHours } from "date-fns"; + +export type ExpiryOption = { id: string; label: string; value: Date | null }; + +function parseDate(value: unknown): Date | null { + if (typeof value !== "string") return null; + const date = new Date(value); + return isNaN(date.getTime()) ? null : date; +} + +export function isGradualRolloutDetails(details: unknown): boolean { + if (details == null || typeof details !== "object") return false; + return ( + "target_rollout_position" in details || "target_rollout_time" in details + ); +} + +export function maxRolloutTime( + evaluations: { ruleId: string; details: unknown }[], + ruleId: string, +): Date | null { + let latest: Date | null = null; + for (const evaluation of evaluations) { + if (evaluation.ruleId !== ruleId) continue; + const details = evaluation.details as { target_rollout_time?: unknown }; + const time = parseDate(details.target_rollout_time); + if (time == null) continue; + if (latest == null || time > latest) latest = time; + } + return latest; +} + +function ruleBasedOption( + details: unknown, + nextEvaluationAt: Date | null, + gradualMaxTime: Date | null, + now: Date, +): { label: string; value: Date } | null { + if (isGradualRolloutDetails(details)) + return gradualMaxTime != null && gradualMaxTime > now + ? { label: "Until rollout completes", value: gradualMaxTime } + : null; + + const d = (details ?? {}) as { + next_window_start?: unknown; + next_deployment_time?: unknown; + }; + + const windowOpen = parseDate(d.next_window_start); + if (windowOpen != null && windowOpen > now) + return { label: "Until deploy window opens", value: windowOpen }; + + const cooldownEnd = parseDate(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; +} + +export function buildExpiryOptions(args: { + details: unknown; + nextEvaluationAt: Date | null; + gradualMaxTime: Date | null; + now: Date; +}): ExpiryOption[] { + const { details, nextEvaluationAt, gradualMaxTime, now } = args; + const ruleBased = ruleBasedOption( + details, + nextEvaluationAt, + gradualMaxTime, + 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; +} + +export type RuleEvaluation = { + ruleId: string; + details: unknown; + nextEvaluationAt?: Date | string | null; +}; + +export function expiryOptionsForRule( + evaluations: RuleEvaluation[], + ruleId: string | undefined, + now: Date, +): ExpiryOption[] { + const ruleEvaluations = evaluations.filter((e) => e.ruleId === ruleId); + const first = ruleEvaluations[0]; + return buildExpiryOptions({ + details: first.details ?? null, + nextEvaluationAt: + first.nextEvaluationAt != null ? new Date(first.nextEvaluationAt) : null, + gradualMaxTime: + ruleId != null ? maxRolloutTime(ruleEvaluations, ruleId) : null, + now, + }); +} From 2eb664d8ac1899788d74d757196a91463027b970 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Wed, 10 Jun 2026 14:12:55 -0400 Subject: [PATCH 2/2] cleanup --- .../release-targets/skip-expiry.ts | 133 ++++++++---------- 1 file changed, 58 insertions(+), 75 deletions(-) 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 index c5f8b7582..4a5d7a09c 100644 --- 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 @@ -2,55 +2,46 @@ import { addHours } from "date-fns"; export type ExpiryOption = { id: string; label: string; value: Date | null }; -function parseDate(value: unknown): Date | null { - if (typeof value !== "string") return null; - const date = new Date(value); - return isNaN(date.getTime()) ? null : date; -} +export type RuleEvaluation = { + ruleId: string; + details: unknown; + nextEvaluationAt?: Date | string | null; +}; -export function isGradualRolloutDetails(details: unknown): boolean { - if (details == null || typeof details !== "object") return false; - return ( - "target_rollout_position" in details || "target_rollout_time" in details - ); -} +type RuleBasedExpiry = { label: string; value: Date }; -export function maxRolloutTime( - evaluations: { ruleId: string; details: unknown }[], - ruleId: string, -): Date | null { - let latest: Date | null = null; - for (const evaluation of evaluations) { - if (evaluation.ruleId !== ruleId) continue; - const details = evaluation.details as { target_rollout_time?: unknown }; - const time = parseDate(details.target_rollout_time); - if (time == null) continue; - if (latest == null || time > latest) latest = time; - } - return latest; +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 ruleBasedOption( +function ruleBasedExpiry( details: unknown, nextEvaluationAt: Date | null, - gradualMaxTime: Date | null, now: Date, -): { label: string; value: Date } | null { - if (isGradualRolloutDetails(details)) - return gradualMaxTime != null && gradualMaxTime > now - ? { label: "Until rollout completes", value: gradualMaxTime } - : null; - +): RuleBasedExpiry | null { const d = (details ?? {}) as { + target_rollout_position?: unknown; + target_rollout_time?: unknown; next_window_start?: unknown; next_deployment_time?: unknown; }; - const windowOpen = parseDate(d.next_window_start); + 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 = parseDate(d.next_deployment_time); + const cooldownEnd = toDate(d.next_deployment_time); if (cooldownEnd != null && cooldownEnd > now) return { label: "Until cooldown ends", value: cooldownEnd }; @@ -60,27 +51,42 @@ function ruleBasedOption( return null; } -export function buildExpiryOptions(args: { - details: unknown; - nextEvaluationAt: Date | null; - gradualMaxTime: Date | null; - now: Date; -}): ExpiryOption[] { - const { details, nextEvaluationAt, gradualMaxTime, now } = args; - const ruleBased = ruleBasedOption( - details, - nextEvaluationAt, - gradualMaxTime, - now, - ); +// `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, - }); + options.push({ id: "rule", label: ruleBased.label, value: ruleBased.value }); for (const hours of [6, 12, 24]) options.push({ id: `${hours}h`, @@ -90,26 +96,3 @@ export function buildExpiryOptions(args: { options.push({ id: "none", label: "No expiration", value: null }); return options; } - -export type RuleEvaluation = { - ruleId: string; - details: unknown; - nextEvaluationAt?: Date | string | null; -}; - -export function expiryOptionsForRule( - evaluations: RuleEvaluation[], - ruleId: string | undefined, - now: Date, -): ExpiryOption[] { - const ruleEvaluations = evaluations.filter((e) => e.ruleId === ruleId); - const first = ruleEvaluations[0]; - return buildExpiryOptions({ - details: first.details ?? null, - nextEvaluationAt: - first.nextEvaluationAt != null ? new Date(first.nextEvaluationAt) : null, - gradualMaxTime: - ruleId != null ? maxRolloutTime(ruleEvaluations, ruleId) : null, - now, - }); -}