Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ const getRules = (policy: Policy) => {
rules.push("deployment-version-selector");

if (policy.concurrency != null) rules.push("concurrency");
if (policy.environmentVersionRollout != null)
rules.push("environment-version-rollout");

return rules;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from "@radix-ui/react-icons";
import {
IconArrowsSplit,
IconCalendar,
IconCalendarTime,
IconFilter,
IconUserCheck,
Expand All @@ -21,7 +22,8 @@ export type RuleType =
| "release-dependency"
| "approval-gate"
| "deployment-version-selector"
| "concurrency";
| "concurrency"
| "environment-version-rollout";

export const ruleTypeIcons: Record<
RuleType,
Expand All @@ -30,6 +32,9 @@ export const ruleTypeIcons: Record<
"deny-window": (props) => (
<IconCalendarTime className={cn("size-3 text-blue-400", props.className)} />
),
"environment-version-rollout": (props) => (
<IconCalendar className={cn("size-3 text-blue-400", props.className)} />
),
"gradual-rollout": (props) => (
<ArrowDownIcon className={cn("size-3 text-green-400", props.className)} />
),
Expand Down Expand Up @@ -68,6 +73,7 @@ export const ruleTypeLabels: Record<RuleType, string> = {
"approval-gate": "Approval Gate",
"deployment-version-selector": "Deployment Version Selector",
concurrency: "Concurrency",
"environment-version-rollout": "Gradual Rollout",
} as const;

export const getRuleTypeLabel = (type: RuleType) => {
Expand All @@ -91,6 +97,8 @@ export const ruleTypeColors: Record<RuleType, string> = {
"bg-purple-500/10 text-purple-400 hover:bg-purple-500/20 border-purple-500/50",
concurrency:
"bg-yellow-500/10 text-yellow-400 hover:bg-yellow-500/20 border-yellow-500/50",
"environment-version-rollout":
"bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 border-blue-500/50",
} as const;

export const getTypeColorClass = (type: RuleType): string => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
import type React from "react";
import { useState } from "react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { IconCircleCheck, IconClock, IconTag } from "@tabler/icons-react";
import {
IconCalendar,
IconCircleCheck,
IconClock,
IconTag,
} from "@tabler/icons-react";

import { Tabs, TabsList, TabsTrigger } from "@ctrlplane/ui/tabs";

Expand All @@ -25,6 +30,7 @@ export const PolicyTabs: React.FC = () => {
const deploymentFlowUrl = editUrls.deploymentFlow();
const qualitySecurityUrl = editUrls.qualitySecurity();
const concurrencyUrl = editUrls.concurrency();
const rolloutsUrl = editUrls.rollouts();

const pathname = usePathname();

Expand All @@ -35,6 +41,7 @@ export const PolicyTabs: React.FC = () => {
if (pathname === deploymentFlowUrl) return "deployment-flow";
if (pathname === qualitySecurityUrl) return "quality-security";
if (pathname === concurrencyUrl) return "concurrency";
if (pathname === rolloutsUrl) return "rollouts";
return "overview";
};

Expand All @@ -49,6 +56,7 @@ export const PolicyTabs: React.FC = () => {
if (value === "deployment-flow") router.push(deploymentFlowUrl);
if (value === "quality-security") router.push(qualitySecurityUrl);
if (value === "concurrency") router.push(concurrencyUrl);
if (value === "rollouts") router.push(rolloutsUrl);
setActiveTab(value);
};

Expand Down Expand Up @@ -79,6 +87,10 @@ export const PolicyTabs: React.FC = () => {
<IconCircleCheck className="h-4 w-4" />
Approval Gates
</TabsTrigger>
<TabsTrigger value="rollouts" className="flex items-center gap-1">
<IconCalendar className="h-4 w-4" />
Rollouts
</TabsTrigger>
</TabsList>
</Tabs>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ export const PolicyEditTabs: React.FC = () => {
description: "Deployment safety measures",
href: policyEditUrls.qualitySecurity(),
},
{
id: "rollouts",
label: "Rollouts",
description: "Control the rollout of deployments",
href: policyEditUrls.rollouts(),
},
];

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,22 +85,19 @@ export const PolicyFormContextProvider: React.FC<{
}

return updatePolicy
.mutateAsync({
id: policy.id,
data: { ...data, targets },
})
.mutateAsync({ id: policy.id, data: { ...data, targets } })
.then(() => {
toast.success("Policy updated successfully");
form.reset(data);
router.refresh();
utils.policy.byId.invalidate();
utils.policy.list.invalidate();
})
.catch((error) => {
.catch((error) =>
toast.error("Failed to update policy", {
description: error.message,
});
});
}),
);
});

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"use client";

import React from "react";

import { PositionGrowthFactor } from "./_components/PositionGrowthFactor";
import { RolloutPreview } from "./_components/RolloutPreview";
import { RolloutSubmit } from "./_components/RolloutSubmit";
import { RolloutTypeSelector } from "./_components/RolloutTypeSelector";
import { TimeScaleInterval } from "./_components/TimeScaleInterval";

const Header: React.FC = () => (
<div className="max-w-xl space-y-1">
<h2 className="text-lg font-semibold">Rollouts</h2>
<p className="text-sm text-muted-foreground">
Control the rollout of deployments
</p>
</div>
);

export const EditRollouts: React.FC = () => (
<div className="grid grid-cols-2 gap-4">
<div className="col-span-1 space-y-6">
<Header />
<RolloutTypeSelector />
<TimeScaleInterval />
<PositionGrowthFactor />
<RolloutSubmit />
</div>
<div className="col-span-1">
<RolloutPreview />
</div>
</div>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@ctrlplane/ui/form";
import { Input } from "@ctrlplane/ui/input";

import { usePolicyFormContext } from "../../_components/PolicyFormContext";

export const PositionGrowthFactor: React.FC = () => {
const { form } = usePolicyFormContext();

const rolloutType = form.watch("environmentVersionRollout.rolloutType");
const isExponential = rolloutType?.includes("exponential");

if (!isExponential) return null;

return (
<FormField
control={form.control}
name="environmentVersionRollout.positionGrowthFactor"
render={({ field }) => (
<FormItem>
<FormLabel>Position Growth Factor</FormLabel>
<FormDescription>
Controls how strongly queue position influences delay — higher
values result in a smoother, slower rollout curve.
</FormDescription>
<FormControl>
<Input type="number" {...field} className="w-20" />
</FormControl>
</FormItem>
)}
/>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import type { TooltipProps } from "recharts";
import type {
NameType,
ValueType,
} from "recharts/types/component/DefaultTooltipContent";
import { useState } from "react";
import prettyMilliseconds from "pretty-ms";
import {
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";

import * as schema from "@ctrlplane/db/schema";
import { Input } from "@ctrlplane/ui/input";
import { toast } from "@ctrlplane/ui/toast";

import { usePolicyFormContext } from "../../_components/PolicyFormContext";
import { RolloutTypeToOffsetFunction } from "./equations";

const PrettyYAxisTick = (props: any) => {
const { payload } = props;
const { value } = payload;

const minutes = Number.parseFloat(value);
const ms = Math.round(minutes * 60_000);

const prettyString = prettyMilliseconds(ms, {
unitCount: 2,
compact: true,
verbose: false,
});

return (
<g>
<text {...props} fontSize={14} dy={5}>
{prettyString}
</text>
</g>
);
};

const PrettyTooltip = (props: TooltipProps<ValueType, NameType>) => {
const { payload } = props;
const position = payload?.[0]?.payload?.x;
const time = Math.round(payload?.[0]?.payload?.y);
const timeMs = time * 60_000;
const timePretty = Number.isNaN(timeMs)
? "0 minutes"
: prettyMilliseconds(timeMs, { verbose: true });

return (
<div className="rounded-md border bg-neutral-900 p-2 text-sm">
<p>Rollout position: {position}</p>
<p>Time: {timePretty}</p>
</div>
);
};

export const RolloutPreview: React.FC = () => {
const { form } = usePolicyFormContext();
const [numResources, setNumResources] = useState<number>(10);

const environmentVersionRollout = form.watch("environmentVersionRollout");
if (!environmentVersionRollout) return null;

const rolloutType = environmentVersionRollout.rolloutType ?? "linear";
const dbRolloutType =
schema.apiRolloutTypeToDBRolloutType[rolloutType] ??
schema.RolloutType.Linear;

const offsetFunction = RolloutTypeToOffsetFunction[dbRolloutType](
environmentVersionRollout.positionGrowthFactor ?? 1,
environmentVersionRollout.timeScaleInterval,
10,
);

const chartData = Array.from({ length: numResources }, (_, i) => ({
x: i,
y: offsetFunction(i),
}));

return (
<div className="flex flex-col gap-4">
<h2 className="text-lg font-medium">Preview Rollout</h2>
<div className="flex items-center gap-2">
<span className="text-nowrap text-sm text-muted-foreground">
Number of resources:{" "}
</span>
<Input
type="number"
value={numResources}
onChange={(e) => {
const { valueAsNumber } = e.target;
if (Number.isNaN(valueAsNumber)) {
toast.error("Invalid number of resources for preview widget");
return;
}
setNumResources(valueAsNumber);
}}
min={1}
className="w-24"
/>
</div>
<div className="rounded-md border p-4">
<ResponsiveContainer width="100%" height={400}>
<LineChart
data={chartData}
margin={{ top: 20, right: 20, bottom: 20, left: 20 }}
>
<XAxis
dataKey="x"
label={{
value: "Rollout position (0-indexed)",
dy: 20,
}}
tick={{ fontSize: 14 }}
/>
<YAxis
label={{
value: "Time (minutes)",
angle: -90,
dx: -20,
}}
tick={PrettyYAxisTick}
/>
<Line type="monotone" dataKey="y" stroke="#8884d8" dot={false} />
<Tooltip content={PrettyTooltip} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Button } from "@ctrlplane/ui/button";

import { usePolicyFormContext } from "../../_components/PolicyFormContext";

export const RolloutSubmit: React.FC = () => {
const { form } = usePolicyFormContext();

return (
<Button
type="submit"
disabled={form.formState.isSubmitting || !form.formState.isDirty}
className="w-fit"
>
Save
</Button>
);
};
Loading
Loading