diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(raw)/[environmentId]/_components/RolloutCurve.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(raw)/[environmentId]/_components/RolloutCurve.tsx new file mode 100644 index 000000000..911ae8675 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(raw)/[environmentId]/_components/RolloutCurve.tsx @@ -0,0 +1,194 @@ +"use client"; + +import type { TooltipProps } from "recharts"; +import type { + NameType, + ValueType, +} from "recharts/types/component/DefaultTooltipContent"; +import { useParams } from "next/navigation"; +import { formatDistanceToNowStrict, isAfter } from "date-fns"; +import prettyMilliseconds from "pretty-ms"; +import { + Line, + LineChart, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@ctrlplane/ui/card"; + +import type { RolloutInfo } from "../_utils/rollout"; +import { RolloutTypeToOffsetFunction } from "~/app/[workspaceSlug]/(app)/policies/[policyId]/edit/rollouts/_components/equations"; +import { api } from "~/trpc/react"; +import { getCurrentRolloutPosition } from "../_utils/rollout"; + +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 ( + + + {prettyString} + + + ); +}; + +const getRolloutTimeMessage = (rolloutTime: Date | null) => { + if (rolloutTime == null) return "Rollout not started"; + + const now = new Date(); + const isInFuture = isAfter(rolloutTime, now); + + const distanceToNow = formatDistanceToNowStrict(rolloutTime, { + addSuffix: true, + }); + if (isInFuture) return `Version rolls out in ${distanceToNow}`; + + return `Version rolled out ${distanceToNow}`; +}; + +const PrettyTooltip = ( + props: TooltipProps & { + rolloutInfoList: RolloutInfo["releaseTargetRolloutInfo"]; + }, +) => { + const { label: position } = props; + + const releaseTarget = props.rolloutInfoList.at(Number(position)); + + if (releaseTarget == null) return null; + + const resourceName = releaseTarget.resource.name; + const rolloutTimeMessage = getRolloutTimeMessage(releaseTarget.rolloutTime); + + return ( +
+

Resource: {resourceName}

+

Rollout position: {position}

+

{rolloutTimeMessage}

+
+ ); +}; + +const RolloutCurve: React.FC<{ + chartData: { x: number; y: number }[]; + currentRolloutPosition: number; + rolloutInfoList: RolloutInfo["releaseTargetRolloutInfo"]; +}> = ({ chartData, currentRolloutPosition, rolloutInfoList }) => { + return ( +
+ + + + + + + PrettyTooltip({ ...props, rolloutInfoList })} + /> + + +
+ ); +}; + +export const RolloutCurveChart: React.FC = () => { + const { releaseId: versionId, environmentId } = useParams<{ + releaseId: string; + environmentId: string; + }>(); + + const { data: rolloutInfo } = api.policy.rollout.list.useQuery( + { environmentId, versionId }, + { refetchInterval: 10_000 }, + ); + + const rolloutPolicy = rolloutInfo?.rolloutPolicy; + const numReleaseTargets = rolloutInfo?.releaseTargetRolloutInfo.length ?? 0; + + const rolloutType = rolloutPolicy?.rolloutType ?? "linear"; + const timeScaleInterval = rolloutPolicy?.timeScaleInterval ?? 0; + const positionGrowthFactor = rolloutPolicy?.positionGrowthFactor ?? 1; + + const offsetFunction = RolloutTypeToOffsetFunction[rolloutType]( + positionGrowthFactor, + timeScaleInterval, + numReleaseTargets, + ); + + const chartData = Array.from({ length: numReleaseTargets }, (_, i) => ({ + x: i, + y: offsetFunction(i), + })); + + const currentRolloutPosition = + getCurrentRolloutPosition(rolloutInfo?.releaseTargetRolloutInfo ?? []) ?? 0; + + console.log(currentRolloutPosition); + + return ( + + + Rollout curve + + View the rollout curve for the deployment. + + + + + + + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(raw)/[environmentId]/_components/RolloutDistributionCard.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(raw)/[environmentId]/_components/RolloutDistributionCard.tsx new file mode 100644 index 000000000..a999ff96e --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(raw)/[environmentId]/_components/RolloutDistributionCard.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@ctrlplane/ui/card"; + +import { RolloutPieChart } from "./RolloutPieChart"; + +export const RolloutDistributionCard: React.FC<{ deploymentId: string }> = ( + props, +) => { + return ( + + + Version distribution + + + + + + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(raw)/[environmentId]/_components/RolloutPercentCard.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(raw)/[environmentId]/_components/RolloutPercentCard.tsx new file mode 100644 index 000000000..fb13cac81 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(raw)/[environmentId]/_components/RolloutPercentCard.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useParams } from "next/navigation"; + +import { Card, CardContent, CardHeader, CardTitle } from "@ctrlplane/ui/card"; + +import { api } from "~/trpc/react"; +import { getCurrentRolloutPosition } from "../_utils/rollout"; + +export const RolloutPercentCard: React.FC = () => { + const { releaseId: versionId, environmentId } = useParams<{ + releaseId: string; + environmentId: string; + }>(); + + const { data: rolloutInfo } = api.policy.rollout.list.useQuery( + { environmentId, versionId }, + { refetchInterval: 10_000 }, + ); + + const currentRolloutPosition = + getCurrentRolloutPosition(rolloutInfo?.releaseTargetRolloutInfo ?? []) ?? 0; + + const maxPosition = rolloutInfo?.releaseTargetRolloutInfo.length ?? 0; + + const percentComplete = + maxPosition === 0 + ? 0 + : Math.round((currentRolloutPosition / maxPosition) * 100); + + return ( + + + Rollout progress + + + {percentComplete}% +
+
+ Progress + + {currentRolloutPosition} / {maxPosition} + +
+
+
+
+
+ + + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(raw)/[environmentId]/_components/RolloutPieChart.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(raw)/[environmentId]/_components/RolloutPieChart.tsx new file mode 100644 index 000000000..67e6cf39c --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(raw)/[environmentId]/_components/RolloutPieChart.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { Cell, Pie, PieChart } from "recharts"; + +import { ChartContainer, ChartTooltip } from "@ctrlplane/ui/chart"; + +import { COLORS } from "../_utils/colors"; +import { useChartData } from "../_utils/useChartData"; + +export const RolloutPieChart: React.FC<{ deploymentId: string }> = ({ + deploymentId, +}) => { + const { environmentId } = useParams<{ environmentId: string }>(); + const versionCounts = useChartData(deploymentId, environmentId); + + return ( + + + { + if (active && payload?.length) { + return ( +
+
{payload[0]?.name}
+
+ {payload[0]?.value} +
+
+ ); + } + }} + /> + + {versionCounts.map((_, index) => ( + + ))} + +
+
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(raw)/[environmentId]/_utils/colors.ts b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(raw)/[environmentId]/_utils/colors.ts new file mode 100644 index 000000000..1b6380dc8 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(raw)/[environmentId]/_utils/colors.ts @@ -0,0 +1,16 @@ +import colors from "tailwindcss/colors"; + +export const COLORS = [ + colors.blue[500], + colors.green[500], + colors.yellow[500], + colors.red[500], + colors.purple[500], + colors.amber[500], + colors.cyan[500], + colors.fuchsia[500], + colors.lime[500], + colors.orange[500], + colors.pink[500], + colors.teal[500], +]; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(raw)/[environmentId]/_utils/rollout.ts b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(raw)/[environmentId]/_utils/rollout.ts new file mode 100644 index 000000000..3e09f3deb --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(raw)/[environmentId]/_utils/rollout.ts @@ -0,0 +1,17 @@ +import type { RouterOutputs } from "@ctrlplane/api"; +import { isAfter } from "date-fns"; + +export type RolloutInfo = RouterOutputs["policy"]["rollout"]["list"]; + +export const getCurrentRolloutPosition = ( + rolloutInfoList: RolloutInfo["releaseTargetRolloutInfo"], +) => { + const now = new Date(); + const next = rolloutInfoList.find( + (info) => info.rolloutTime != null && isAfter(info.rolloutTime, now), + ); + + if (next == null) return null; + + return next.rolloutPosition; +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(raw)/[environmentId]/_utils/useChartData.ts b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(raw)/[environmentId]/_utils/useChartData.ts new file mode 100644 index 000000000..61fe5b975 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(raw)/[environmentId]/_utils/useChartData.ts @@ -0,0 +1,11 @@ +import { api } from "~/trpc/react"; + +export const useChartData = (deploymentId: string, environmentId: string) => { + const { data } = + api.dashboard.widget.data.deploymentVersionDistribution.useQuery( + { deploymentId, environmentIds: [environmentId] }, + { refetchInterval: 10_000 }, + ); + + return data ?? []; +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(raw)/[environmentId]/layout.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(raw)/[environmentId]/layout.tsx index e69de29bb..c4e89ef2e 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(raw)/[environmentId]/layout.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(raw)/[environmentId]/layout.tsx @@ -0,0 +1,80 @@ +import React from "react"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { IconArrowLeft } from "@tabler/icons-react"; + +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@ctrlplane/ui/breadcrumb"; +import { Separator } from "@ctrlplane/ui/separator"; + +import { PageHeader } from "~/app/[workspaceSlug]/(app)/_components/PageHeader"; +import { urls } from "~/app/urls"; +import { api } from "~/trpc/server"; + +export default async function ReleaseLayout(props: { + children: React.ReactNode; + params: Promise<{ + workspaceSlug: string; + systemSlug: string; + deploymentSlug: string; + releaseId: string; + environmentId: string; + }>; +}) { + const params = await props.params; + const version = await api.deployment.version.byId(params.releaseId); + if (version == null) notFound(); + + const environment = await api.environment.byId(params.environmentId); + if (environment == null) notFound(); + + const deployment = await api.deployment.byId(version.deploymentId); + const systemUrls = urls + .workspace(params.workspaceSlug) + .system(params.systemSlug); + const deploymentUrls = systemUrls.deployment(params.deploymentSlug); + const releaseUrl = deploymentUrls.release(params.releaseId).jobs(); + + return ( +
+ +
+ + + + + + + + + Deployments + + + + + + {deployment.name} + + + + + {version.tag} + + + + {environment.name} + + + +
+
+
{props.children}
+
+ ); +} diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(raw)/[environmentId]/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(raw)/[environmentId]/page.tsx index efc794260..96b4556ce 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(raw)/[environmentId]/page.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(deploy)/(raw)/systems/[systemSlug]/(raw)/deployments/[deploymentSlug]/(raw)/releases/[releaseId]/(raw)/[environmentId]/page.tsx @@ -1,3 +1,47 @@ -export default function EnvironmentVersionPage() { - return
EnvironmentVersionPage
; +import { notFound } from "next/navigation"; + +import { api } from "~/trpc/server"; +import { RolloutCurveChart } from "./_components/RolloutCurve"; +import { RolloutDistributionCard } from "./_components/RolloutDistributionCard"; +import { RolloutPercentCard } from "./_components/RolloutPercentCard"; + +export default async function EnvironmentVersionPage(props: { + params: Promise<{ + workspaceSlug: string; + systemSlug: string; + deploymentSlug: string; + releaseId: string; + environmentId: string; + }>; +}) { + const params = await props.params; + const version = await api.deployment.version.byId(params.releaseId); + if (version == null) notFound(); + + const environment = await api.environment.byId(params.environmentId); + if (environment == null) notFound(); + + const deployment = await api.deployment.bySlug(params); + if (deployment == null) notFound(); + + return ( +
+
+

Gradual rollout

+

+ Rolling out the version {version.tag} to the environment{" "} + {environment.name} +

+
+
+
+ +
+
+ +
+
+ +
+ ); } diff --git a/packages/api/src/router/policy/rollout.ts b/packages/api/src/router/policy/rollout.ts new file mode 100644 index 000000000..1934ae7d9 --- /dev/null +++ b/packages/api/src/router/policy/rollout.ts @@ -0,0 +1,124 @@ +import type { Tx } from "@ctrlplane/db"; +import { z } from "zod"; + +import { and, eq, takeFirst } from "@ctrlplane/db"; +import * as schema from "@ctrlplane/db/schema"; +import { + getRolloutInfoForReleaseTarget, + mergePolicies, +} from "@ctrlplane/rule-engine"; +import { getApplicablePoliciesWithoutResourceScope } from "@ctrlplane/rule-engine/db"; +import { Permission } from "@ctrlplane/validators/auth"; + +import { createTRPCRouter, protectedProcedure } from "../../trpc"; + +const getVersion = async (db: Tx, versionId: string) => + db + .select() + .from(schema.deploymentVersion) + .where(eq(schema.deploymentVersion.id, versionId)) + .then(takeFirst); + +const getReleaseTargets = async ( + db: Tx, + deploymentId: string, + environmentId: string, +) => + db + .select() + .from(schema.releaseTarget) + .innerJoin( + schema.resource, + eq(schema.releaseTarget.resourceId, schema.resource.id), + ) + .innerJoin( + schema.deployment, + eq(schema.releaseTarget.deploymentId, schema.deployment.id), + ) + .innerJoin( + schema.environment, + eq(schema.releaseTarget.environmentId, schema.environment.id), + ) + .where( + and( + eq(schema.releaseTarget.deploymentId, deploymentId), + eq(schema.releaseTarget.environmentId, environmentId), + ), + ) + .then((rows) => + rows.map((row) => ({ + ...row.release_target, + resource: row.resource, + deployment: row.deployment, + environment: row.environment, + })), + ); + +const getPolicy = async ( + db: Tx, + environmentId: string, + deploymentId: string, +) => { + const policies = await getApplicablePoliciesWithoutResourceScope( + db, + environmentId, + deploymentId, + ); + + return mergePolicies(policies); +}; + +export const rolloutRouter = createTRPCRouter({ + list: protectedProcedure + .input( + z.object({ + environmentId: z.string().uuid(), + versionId: z.string().uuid(), + }), + ) + .meta({ + authorizationCheck: ({ canUser, input }) => + canUser.perform(Permission.EnvironmentGet).on({ + type: "environment", + id: input.environmentId, + }), + }) + .query(async ({ ctx, input }) => { + const { environmentId, versionId } = input; + + const version = await getVersion(ctx.db, versionId); + + const releaseTargets = await getReleaseTargets( + ctx.db, + version.deploymentId, + environmentId, + ); + + const policy = await getPolicy( + ctx.db, + environmentId, + version.deploymentId, + ); + + const releaseTargetRolloutInfoPromises = releaseTargets.map( + (releaseTarget) => + getRolloutInfoForReleaseTarget( + ctx.db, + releaseTarget, + policy, + version, + ), + ); + + const releaseTargetRolloutInfo = await Promise.all( + releaseTargetRolloutInfoPromises, + ).then((rows) => + rows.sort((a, b) => a.rolloutPosition - b.rolloutPosition), + ); + + return { + releaseTargetRolloutInfo, + rolloutPolicy: policy?.environmentVersionRollout, + }; + }), +}); diff --git a/packages/api/src/router/policy/router.ts b/packages/api/src/router/policy/router.ts index 3d07b1b25..21e63e4fd 100644 --- a/packages/api/src/router/policy/router.ts +++ b/packages/api/src/router/policy/router.ts @@ -22,6 +22,7 @@ import { createTRPCRouter, protectedProcedure } from "../../trpc"; import { policyAiRouter } from "./ai"; import { policyDenyWindowRouter } from "./deny-window"; import { evaluateRouter } from "./evaluate"; +import { rolloutRouter } from "./rollout"; import { policyTargetRouter } from "./target"; export const policyRouter = createTRPCRouter({ @@ -29,6 +30,7 @@ export const policyRouter = createTRPCRouter({ target: policyTargetRouter, denyWindow: policyDenyWindowRouter, evaluate: evaluateRouter, + rollout: rolloutRouter, list: protectedProcedure .meta({