Skip to content

Commit 87fbec7

Browse files
feat: env version rollout graphs (ctrlplanedev#612)
1 parent c346388 commit 87fbec7

File tree

11 files changed

+607
-2
lines changed

11 files changed

+607
-2
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
"use client";
2+
3+
import type { TooltipProps } from "recharts";
4+
import type {
5+
NameType,
6+
ValueType,
7+
} from "recharts/types/component/DefaultTooltipContent";
8+
import { useParams } from "next/navigation";
9+
import { formatDistanceToNowStrict, isAfter } from "date-fns";
10+
import prettyMilliseconds from "pretty-ms";
11+
import {
12+
Line,
13+
LineChart,
14+
ReferenceLine,
15+
ResponsiveContainer,
16+
Tooltip,
17+
XAxis,
18+
YAxis,
19+
} from "recharts";
20+
21+
import {
22+
Card,
23+
CardContent,
24+
CardDescription,
25+
CardHeader,
26+
CardTitle,
27+
} from "@ctrlplane/ui/card";
28+
29+
import type { RolloutInfo } from "../_utils/rollout";
30+
import { RolloutTypeToOffsetFunction } from "~/app/[workspaceSlug]/(app)/policies/[policyId]/edit/rollouts/_components/equations";
31+
import { api } from "~/trpc/react";
32+
import { getCurrentRolloutPosition } from "../_utils/rollout";
33+
34+
const PrettyYAxisTick = (props: any) => {
35+
const { payload } = props;
36+
const { value } = payload;
37+
38+
const minutes = Number.parseFloat(value);
39+
const ms = Math.round(minutes * 60_000);
40+
41+
const prettyString = prettyMilliseconds(ms, {
42+
unitCount: 2,
43+
compact: true,
44+
verbose: false,
45+
});
46+
47+
return (
48+
<g>
49+
<text {...props} fontSize={14} dy={5}>
50+
{prettyString}
51+
</text>
52+
</g>
53+
);
54+
};
55+
56+
const getRolloutTimeMessage = (rolloutTime: Date | null) => {
57+
if (rolloutTime == null) return "Rollout not started";
58+
59+
const now = new Date();
60+
const isInFuture = isAfter(rolloutTime, now);
61+
62+
const distanceToNow = formatDistanceToNowStrict(rolloutTime, {
63+
addSuffix: true,
64+
});
65+
if (isInFuture) return `Version rolls out in ${distanceToNow}`;
66+
67+
return `Version rolled out ${distanceToNow}`;
68+
};
69+
70+
const PrettyTooltip = (
71+
props: TooltipProps<ValueType, NameType> & {
72+
rolloutInfoList: RolloutInfo["releaseTargetRolloutInfo"];
73+
},
74+
) => {
75+
const { label: position } = props;
76+
77+
const releaseTarget = props.rolloutInfoList.at(Number(position));
78+
79+
if (releaseTarget == null) return null;
80+
81+
const resourceName = releaseTarget.resource.name;
82+
const rolloutTimeMessage = getRolloutTimeMessage(releaseTarget.rolloutTime);
83+
84+
return (
85+
<div className="rounded-md border bg-neutral-900 p-2 text-sm">
86+
<p>Resource: {resourceName}</p>
87+
<p>Rollout position: {position}</p>
88+
<p>{rolloutTimeMessage}</p>
89+
</div>
90+
);
91+
};
92+
93+
const RolloutCurve: React.FC<{
94+
chartData: { x: number; y: number }[];
95+
currentRolloutPosition: number;
96+
rolloutInfoList: RolloutInfo["releaseTargetRolloutInfo"];
97+
}> = ({ chartData, currentRolloutPosition, rolloutInfoList }) => {
98+
return (
99+
<div className="h-[300px] w-full">
100+
<ResponsiveContainer width="100%" height={300}>
101+
<LineChart
102+
data={chartData}
103+
margin={{ top: 20, right: 20, bottom: 20, left: 20 }}
104+
>
105+
<XAxis
106+
dataKey="x"
107+
label={{
108+
value: "Rollout position (0-indexed)",
109+
dy: 20,
110+
}}
111+
tick={{ fontSize: 14 }}
112+
/>
113+
<YAxis
114+
label={{
115+
value: "Time (minutes)",
116+
angle: -90,
117+
dx: -20,
118+
}}
119+
tick={PrettyYAxisTick}
120+
/>
121+
<Line type="monotone" dataKey="y" stroke="#8884d8" dot={false} />
122+
<ReferenceLine
123+
x={currentRolloutPosition}
124+
stroke="#9ca3af"
125+
strokeDasharray="5 5"
126+
strokeWidth={2}
127+
label={{
128+
value: "Current rollout position",
129+
position: "top",
130+
fill: "#9ca3af",
131+
fontSize: 12,
132+
}}
133+
/>
134+
<Tooltip
135+
content={(props) => PrettyTooltip({ ...props, rolloutInfoList })}
136+
/>
137+
</LineChart>
138+
</ResponsiveContainer>
139+
</div>
140+
);
141+
};
142+
143+
export const RolloutCurveChart: React.FC = () => {
144+
const { releaseId: versionId, environmentId } = useParams<{
145+
releaseId: string;
146+
environmentId: string;
147+
}>();
148+
149+
const { data: rolloutInfo } = api.policy.rollout.list.useQuery(
150+
{ environmentId, versionId },
151+
{ refetchInterval: 10_000 },
152+
);
153+
154+
const rolloutPolicy = rolloutInfo?.rolloutPolicy;
155+
const numReleaseTargets = rolloutInfo?.releaseTargetRolloutInfo.length ?? 0;
156+
157+
const rolloutType = rolloutPolicy?.rolloutType ?? "linear";
158+
const timeScaleInterval = rolloutPolicy?.timeScaleInterval ?? 0;
159+
const positionGrowthFactor = rolloutPolicy?.positionGrowthFactor ?? 1;
160+
161+
const offsetFunction = RolloutTypeToOffsetFunction[rolloutType](
162+
positionGrowthFactor,
163+
timeScaleInterval,
164+
numReleaseTargets,
165+
);
166+
167+
const chartData = Array.from({ length: numReleaseTargets }, (_, i) => ({
168+
x: i,
169+
y: offsetFunction(i),
170+
}));
171+
172+
const currentRolloutPosition =
173+
getCurrentRolloutPosition(rolloutInfo?.releaseTargetRolloutInfo ?? []) ?? 0;
174+
175+
console.log(currentRolloutPosition);
176+
177+
return (
178+
<Card className="p-2">
179+
<CardHeader>
180+
<CardTitle>Rollout curve</CardTitle>
181+
<CardDescription>
182+
View the rollout curve for the deployment.
183+
</CardDescription>
184+
</CardHeader>
185+
<CardContent className="flex flex-col gap-4 p-4">
186+
<RolloutCurve
187+
chartData={chartData}
188+
currentRolloutPosition={Number(currentRolloutPosition)}
189+
rolloutInfoList={rolloutInfo?.releaseTargetRolloutInfo ?? []}
190+
/>
191+
</CardContent>
192+
</Card>
193+
);
194+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"use client";
2+
3+
import { Card, CardContent, CardHeader, CardTitle } from "@ctrlplane/ui/card";
4+
5+
import { RolloutPieChart } from "./RolloutPieChart";
6+
7+
export const RolloutDistributionCard: React.FC<{ deploymentId: string }> = (
8+
props,
9+
) => {
10+
return (
11+
<Card className="p-2">
12+
<CardHeader>
13+
<CardTitle>Version distribution</CardTitle>
14+
</CardHeader>
15+
<CardContent className="flex w-full flex-col gap-4 p-4">
16+
<RolloutPieChart {...props} />
17+
</CardContent>
18+
</Card>
19+
);
20+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"use client";
2+
3+
import { useParams } from "next/navigation";
4+
5+
import { Card, CardContent, CardHeader, CardTitle } from "@ctrlplane/ui/card";
6+
7+
import { api } from "~/trpc/react";
8+
import { getCurrentRolloutPosition } from "../_utils/rollout";
9+
10+
export const RolloutPercentCard: React.FC = () => {
11+
const { releaseId: versionId, environmentId } = useParams<{
12+
releaseId: string;
13+
environmentId: string;
14+
}>();
15+
16+
const { data: rolloutInfo } = api.policy.rollout.list.useQuery(
17+
{ environmentId, versionId },
18+
{ refetchInterval: 10_000 },
19+
);
20+
21+
const currentRolloutPosition =
22+
getCurrentRolloutPosition(rolloutInfo?.releaseTargetRolloutInfo ?? []) ?? 0;
23+
24+
const maxPosition = rolloutInfo?.releaseTargetRolloutInfo.length ?? 0;
25+
26+
const percentComplete =
27+
maxPosition === 0
28+
? 0
29+
: Math.round((currentRolloutPosition / maxPosition) * 100);
30+
31+
return (
32+
<Card className="flex h-full flex-col gap-16 p-4">
33+
<CardHeader>
34+
<CardTitle>Rollout progress</CardTitle>
35+
</CardHeader>
36+
<CardContent className="flex h-full flex-col items-center justify-between">
37+
<span className="text-5xl font-bold">{percentComplete}%</span>
38+
<div className="w-full">
39+
<div className="mb-2 flex justify-between text-sm text-muted-foreground">
40+
<span>Progress</span>
41+
<span>
42+
{currentRolloutPosition} / {maxPosition}
43+
</span>
44+
</div>
45+
<div className="h-2 w-full rounded-full bg-muted">
46+
<div
47+
className="h-full rounded-full bg-primary transition-all duration-300"
48+
style={{ width: `${percentComplete}%` }}
49+
/>
50+
</div>
51+
</div>
52+
</CardContent>
53+
</Card>
54+
);
55+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"use client";
2+
3+
import { useParams } from "next/navigation";
4+
import { Cell, Pie, PieChart } from "recharts";
5+
6+
import { ChartContainer, ChartTooltip } from "@ctrlplane/ui/chart";
7+
8+
import { COLORS } from "../_utils/colors";
9+
import { useChartData } from "../_utils/useChartData";
10+
11+
export const RolloutPieChart: React.FC<{ deploymentId: string }> = ({
12+
deploymentId,
13+
}) => {
14+
const { environmentId } = useParams<{ environmentId: string }>();
15+
const versionCounts = useChartData(deploymentId, environmentId);
16+
17+
return (
18+
<ChartContainer config={{}} className="h-full w-full flex-grow">
19+
<PieChart>
20+
<ChartTooltip
21+
content={({ active, payload }) => {
22+
if (active && payload?.length) {
23+
return (
24+
<div className="flex items-center gap-4 rounded-lg border bg-background p-2 text-xs shadow-sm">
25+
<div className="font-semibold">{payload[0]?.name}</div>
26+
<div className="text-sm text-neutral-400">
27+
{payload[0]?.value}
28+
</div>
29+
</div>
30+
);
31+
}
32+
}}
33+
/>
34+
<Pie data={versionCounts} dataKey="count" nameKey="versionTag">
35+
{versionCounts.map((_, index) => (
36+
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
37+
))}
38+
</Pie>
39+
</PieChart>
40+
</ChartContainer>
41+
);
42+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import colors from "tailwindcss/colors";
2+
3+
export const COLORS = [
4+
colors.blue[500],
5+
colors.green[500],
6+
colors.yellow[500],
7+
colors.red[500],
8+
colors.purple[500],
9+
colors.amber[500],
10+
colors.cyan[500],
11+
colors.fuchsia[500],
12+
colors.lime[500],
13+
colors.orange[500],
14+
colors.pink[500],
15+
colors.teal[500],
16+
];
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { RouterOutputs } from "@ctrlplane/api";
2+
import { isAfter } from "date-fns";
3+
4+
export type RolloutInfo = RouterOutputs["policy"]["rollout"]["list"];
5+
6+
export const getCurrentRolloutPosition = (
7+
rolloutInfoList: RolloutInfo["releaseTargetRolloutInfo"],
8+
) => {
9+
const now = new Date();
10+
const next = rolloutInfoList.find(
11+
(info) => info.rolloutTime != null && isAfter(info.rolloutTime, now),
12+
);
13+
14+
if (next == null) return null;
15+
16+
return next.rolloutPosition;
17+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { api } from "~/trpc/react";
2+
3+
export const useChartData = (deploymentId: string, environmentId: string) => {
4+
const { data } =
5+
api.dashboard.widget.data.deploymentVersionDistribution.useQuery(
6+
{ deploymentId, environmentIds: [environmentId] },
7+
{ refetchInterval: 10_000 },
8+
);
9+
10+
return data ?? [];
11+
};

0 commit comments

Comments
 (0)