diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/environment-drawer/EnvironmentDrawer.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/environment-drawer/EnvironmentDrawer.tsx index 1f60ebdfd..713df53a6 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/environment-drawer/EnvironmentDrawer.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/environment-drawer/EnvironmentDrawer.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState } from "react"; +import React from "react"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { IconDotsVertical, @@ -21,11 +21,38 @@ import { EditFilterForm } from "./Filter"; import { Overview } from "./Overview"; import { ReleaseChannels } from "./ReleaseChannels"; +export enum EnvironmentDrawerTab { + Overview = "overview", + Targets = "targets", + ReleaseChannels = "release-channels", +} + +const tabParam = "tab"; +const useEnvironmentDrawerTab = () => { + const router = useRouter(); + const params = useSearchParams(); + const tab = params.get(tabParam); + + const setTab = (tab: EnvironmentDrawerTab | null) => { + const url = new URL(window.location.href); + if (tab === null) { + url.searchParams.delete(tabParam); + router.replace(`${url.pathname}?${url.searchParams.toString()}`); + return; + } + url.searchParams.set(tabParam, tab); + router.replace(`${url.pathname}?${url.searchParams.toString()}`); + }; + + return { tab, setTab }; +}; + const param = "environment_id"; export const useEnvironmentDrawer = () => { const router = useRouter(); const params = useSearchParams(); const environmentId = params.get(param); + const { tab, setTab } = useEnvironmentDrawerTab(); const setEnvironmentId = (id: string | null) => { const url = new URL(window.location.href); @@ -38,13 +65,17 @@ export const useEnvironmentDrawer = () => { router.replace(`${url.pathname}?${url.searchParams.toString()}`); }; - const removeEnvironmentId = () => setEnvironmentId(null); + const removeEnvironmentId = () => { + setTab(null); + setEnvironmentId(null); + }; - return { environmentId, setEnvironmentId, removeEnvironmentId }; + return { environmentId, setEnvironmentId, removeEnvironmentId, tab, setTab }; }; export const EnvironmentDrawer: React.FC = () => { - const { environmentId, removeEnvironmentId } = useEnvironmentDrawer(); + const { environmentId, removeEnvironmentId, tab, setTab } = + useEnvironmentDrawer(); const isOpen = Boolean(environmentId); const setIsOpen = removeEnvironmentId; const environmentQ = api.environment.byId.useQuery(environmentId ?? "", { @@ -65,8 +96,6 @@ export const EnvironmentDrawer: React.FC = () => { ); const deployments = deploymentsQ.data; - const [activeTab, setActiveTab] = useState("overview"); - const loading = environmentQ.isLoading || workspaceQ.isLoading || deploymentsQ.isLoading; @@ -107,20 +136,20 @@ export const EnvironmentDrawer: React.FC = () => {
setActiveTab("overview")} + active={tab === EnvironmentDrawerTab.Overview || tab == null} + onClick={() => setTab(EnvironmentDrawerTab.Overview)} icon={} label="Overview" /> setActiveTab("targets")} + active={tab === EnvironmentDrawerTab.Targets} + onClick={() => setTab(EnvironmentDrawerTab.Targets)} icon={} label="Targets" /> setActiveTab("release-channels")} + active={tab === EnvironmentDrawerTab.ReleaseChannels} + onClick={() => setTab(EnvironmentDrawerTab.ReleaseChannels)} icon={} label="Release Channels" /> @@ -128,21 +157,22 @@ export const EnvironmentDrawer: React.FC = () => { {environment != null && (
- {activeTab === "overview" && ( + {(tab === EnvironmentDrawerTab.Overview || tab == null) && ( )} - {activeTab === "targets" && workspace != null && ( + {tab === EnvironmentDrawerTab.Targets && workspace != null && ( )} - {activeTab === "release-channels" && deployments != null && ( - - )} + {tab === EnvironmentDrawerTab.ReleaseChannels && + deployments != null && ( + + )}
)}
diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/environment-policy-drawer/EnvironmentPolicyDrawer.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/environment-policy-drawer/EnvironmentPolicyDrawer.tsx index c08405b7e..d786a58d1 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/environment-policy-drawer/EnvironmentPolicyDrawer.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/environment-policy-drawer/EnvironmentPolicyDrawer.tsx @@ -2,7 +2,6 @@ import type * as SCHEMA from "@ctrlplane/db/schema"; import type React from "react"; -import { useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { IconCalendar, @@ -34,11 +33,41 @@ import { ReleaseChannels } from "./ReleaseChannels"; import { ReleaseManagement } from "./ReleaseManagement"; import { RolloutAndTiming } from "./RolloutAndTiming"; +export enum EnvironmentPolicyDrawerTab { + Overview = "overview", + Approval = "approval", + Concurrency = "concurrency", + Management = "management", + ReleaseChannels = "release-channels", + Rollout = "rollout", +} + +const tabParam = "tab"; +const useEnvironmentPolicyDrawerTab = () => { + const router = useRouter(); + const params = useSearchParams(); + const tab = params.get(tabParam) as EnvironmentPolicyDrawerTab | null; + + const setTab = (tab: EnvironmentPolicyDrawerTab | null) => { + const url = new URL(window.location.href); + if (tab === null) { + url.searchParams.delete(tabParam); + router.replace(`${url.pathname}?${url.searchParams.toString()}`); + return; + } + url.searchParams.set(tabParam, tab); + router.replace(`${url.pathname}?${url.searchParams.toString()}`); + }; + + return { tab, setTab }; +}; + const param = "environment_policy_id"; export const useEnvironmentPolicyDrawer = () => { const router = useRouter(); const params = useSearchParams(); const environmentPolicyId = params.get(param); + const { tab, setTab } = useEnvironmentPolicyDrawerTab(); const setEnvironmentPolicyId = (id: string | null) => { const url = new URL(window.location.href); @@ -57,6 +86,8 @@ export const useEnvironmentPolicyDrawer = () => { environmentPolicyId, setEnvironmentPolicyId, removeEnvironmentPolicyId, + tab, + setTab, }; }; @@ -65,7 +96,7 @@ type Deployment = SCHEMA.Deployment & { }; const View: React.FC<{ - activeTab: string; + activeTab: EnvironmentPolicyDrawerTab; environmentPolicy: SCHEMA.EnvironmentPolicy & { releaseWindows: SCHEMA.EnvironmentPolicyReleaseWindow[]; releaseChannels: SCHEMA.ReleaseChannel[]; @@ -73,12 +104,22 @@ const View: React.FC<{ deployments?: Deployment[]; }> = ({ activeTab, environmentPolicy, deployments }) => { return { - overview: , - approval: , - concurrency: , - management: , - rollout: , - "release-channels": deployments != null && ( + [EnvironmentPolicyDrawerTab.Overview]: ( + + ), + [EnvironmentPolicyDrawerTab.Approval]: ( + + ), + [EnvironmentPolicyDrawerTab.Concurrency]: ( + + ), + [EnvironmentPolicyDrawerTab.Management]: ( + + ), + [EnvironmentPolicyDrawerTab.Rollout]: ( + + ), + [EnvironmentPolicyDrawerTab.ReleaseChannels]: deployments != null && ( ), }[activeTab]; @@ -105,7 +146,7 @@ const PolicyDropdownMenu: React.FC<{ ); export const EnvironmentPolicyDrawer: React.FC = () => { - const { environmentPolicyId, removeEnvironmentPolicyId } = + const { environmentPolicyId, removeEnvironmentPolicyId, tab, setTab } = useEnvironmentPolicyDrawer(); const isOpen = Boolean(environmentPolicyId); const setIsOpen = removeEnvironmentPolicyId; @@ -121,8 +162,6 @@ export const EnvironmentPolicyDrawer: React.FC = () => { ); const deployments = deploymentsQ.data; - const [activeTab, setActiveTab] = useState("overview"); - return ( {
setActiveTab("overview")} + active={ + tab === EnvironmentPolicyDrawerTab.Overview || tab == null + } + onClick={() => setTab(EnvironmentPolicyDrawerTab.Overview)} icon={} label="Overview" /> setActiveTab("approval")} + active={tab === EnvironmentPolicyDrawerTab.Approval} + onClick={() => setTab(EnvironmentPolicyDrawerTab.Approval)} icon={} label="Approval & Governance" /> setActiveTab("concurrency")} + active={tab === EnvironmentPolicyDrawerTab.Concurrency} + onClick={() => setTab(EnvironmentPolicyDrawerTab.Concurrency)} icon={} label="Deployment Control" /> setActiveTab("management")} + active={tab === EnvironmentPolicyDrawerTab.Management} + onClick={() => setTab(EnvironmentPolicyDrawerTab.Management)} icon={} label="Release Management" /> setActiveTab("release-channels")} + active={tab === EnvironmentPolicyDrawerTab.ReleaseChannels} + onClick={() => setTab(EnvironmentPolicyDrawerTab.ReleaseChannels)} icon={} label="Release Channels" /> setActiveTab("rollout")} + active={tab === EnvironmentPolicyDrawerTab.Rollout} + onClick={() => setTab(EnvironmentPolicyDrawerTab.Rollout)} icon={} label="Rollout and Timing" /> @@ -190,7 +231,7 @@ export const EnvironmentPolicyDrawer: React.FC = () => { {environmentPolicy != null && (
diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/release-channel-drawer/Overview.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/release-channel-drawer/Overview.tsx index 422b073d1..76d808679 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/release-channel-drawer/Overview.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/release-channel-drawer/Overview.tsx @@ -1,6 +1,10 @@ import type * as SCHEMA from "@ctrlplane/db/schema"; +import type { ReleaseCondition } from "@ctrlplane/validators/releases"; import type React from "react"; -import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { IconExternalLink, IconLoader2 } from "@tabler/icons-react"; +import LZString from "lz-string"; import { z } from "zod"; import { Button } from "@ctrlplane/ui/button"; @@ -15,21 +19,57 @@ import { } from "@ctrlplane/ui/form"; import { Input } from "@ctrlplane/ui/input"; import { Textarea } from "@ctrlplane/ui/textarea"; +import { + defaultCondition, + isEmptyCondition, + isValidReleaseCondition, + releaseCondition, +} from "@ctrlplane/validators/releases"; import { api } from "~/trpc/react"; +import { ReleaseConditionRender } from "../release-condition/ReleaseConditionRender"; +import { ReleaseBadgeList } from "../ReleaseBadgeList"; type OverviewProps = { releaseChannel: SCHEMA.ReleaseChannel; }; +const getFinalFilter = (filter: ReleaseCondition | null) => + filter && !isEmptyCondition(filter) ? filter : undefined; + +const getReleaseFilterUrl = ( + workspaceSlug: string, + + systemSlug?: string, + deploymentSlug?: string, + filter?: ReleaseCondition, +) => { + if (filter == null || systemSlug == null || deploymentSlug == null) + return null; + const baseUrl = `/${workspaceSlug}/systems/${systemSlug}/deployments/${deploymentSlug}`; + const filterHash = LZString.compressToEncodedURIComponent( + JSON.stringify(filter), + ); + return `${baseUrl}/releases?filter=${filterHash}`; +}; + const schema = z.object({ name: z.string().min(1).max(50), description: z.string().max(1000).optional(), + releaseFilter: releaseCondition + .nullable() + .refine((r) => r == null || isValidReleaseCondition(r)), }); export const Overview: React.FC = ({ releaseChannel }) => { + const { workspaceSlug, systemSlug, deploymentSlug } = useParams<{ + workspaceSlug: string; + systemSlug?: string; + deploymentSlug?: string; + }>(); + const defaultValues = { - name: releaseChannel.name, + ...releaseChannel, description: releaseChannel.description ?? undefined, }; const form = useForm({ schema, defaultValues }); @@ -38,19 +78,36 @@ export const Overview: React.FC = ({ releaseChannel }) => { const updateReleaseChannel = api.deployment.releaseChannel.update.useMutation(); - const onSubmit = form.handleSubmit((data) => + const onSubmit = form.handleSubmit((data) => { + const releaseFilter = getFinalFilter(data.releaseFilter); updateReleaseChannel - .mutateAsync({ id: releaseChannel.id, data }) - .then(() => form.reset(data)) + .mutateAsync({ id: releaseChannel.id, data: { ...data, releaseFilter } }) + .then(() => form.reset({ ...data, releaseFilter })) .then(() => utils.deployment.releaseChannel.byId.invalidate(releaseChannel.id), ) - .then(() => router.refresh()), + .then(() => router.refresh()); + }); + + const { deploymentId } = releaseChannel; + const filter = getFinalFilter(form.watch("releaseFilter")); + + const releasesQ = api.release.list.useQuery({ + deploymentId, + filter, + limit: 5, + }); + const releases = releasesQ.data; + const releaseFilterUrl = getReleaseFilterUrl( + workspaceSlug, + systemSlug, + deploymentSlug, + filter, ); return (
- + = ({ releaseChannel }) => { )} /> - + ( + + + Release Filter + {releases != null && ({releases.total})} + {releasesQ.isLoading && ( + + )} + + + + + {releases != null && } + + )} + /> + +
+ + {releaseFilterUrl != null && ( + + + + )} +
); diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/release-channel-drawer/ReleaseChannelDrawer.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/release-channel-drawer/ReleaseChannelDrawer.tsx index a723844c0..e18d07187 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/release-channel-drawer/ReleaseChannelDrawer.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/release-channel-drawer/ReleaseChannelDrawer.tsx @@ -1,16 +1,22 @@ "use client"; import type React from "react"; -import { IconDotsVertical, IconLoader2 } from "@tabler/icons-react"; +import { useState } from "react"; +import { + IconDotsVertical, + IconInfoCircle, + IconLoader2, + IconPlugConnected, +} from "@tabler/icons-react"; import { Button } from "@ctrlplane/ui/button"; import { Drawer, DrawerContent, DrawerTitle } from "@ctrlplane/ui/drawer"; -import { Separator } from "@ctrlplane/ui/separator"; import { api } from "~/trpc/react"; +import { TabButton } from "../TabButton"; import { Overview } from "./Overview"; import { ReleaseChannelDropdown } from "./ReleaseChannelDropdown"; -import { ReleaseFilter } from "./ReleaseFilter"; +import { Usage } from "./Usage"; import { useReleaseChannelDrawer } from "./useReleaseChannelDrawer"; export const ReleaseChannelDrawer: React.FC = () => { @@ -25,20 +31,15 @@ export const ReleaseChannelDrawer: React.FC = () => { ); const releaseChannel = releaseChannelQ.data; - const filter = releaseChannel?.releaseFilter ?? undefined; - const deploymentId = releaseChannel?.deploymentId ?? ""; - const releasesQ = api.release.list.useQuery( - { deploymentId, filter }, - { enabled: isOpen && releaseChannel != null && deploymentId != "" }, - ); + const loading = releaseChannelQ.isLoading; - const loading = releaseChannelQ.isLoading || releasesQ.isLoading; + const [activeTab, setActiveTab] = useState("overview"); return ( {loading && (
@@ -56,10 +57,30 @@ export const ReleaseChannelDrawer: React.FC = () => { -
- - - +
+
+ setActiveTab("overview")} + icon={} + label="Overview" + /> + setActiveTab("usage")} + icon={} + label="Usage" + /> +
+ +
+ {activeTab === "overview" && ( + + )} + {activeTab === "usage" && ( + + )} +
)} diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/release-channel-drawer/ReleaseFilter.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/release-channel-drawer/ReleaseFilter.tsx deleted file mode 100644 index 817ad45d9..000000000 --- a/apps/webservice/src/app/[workspaceSlug]/_components/release-channel-drawer/ReleaseFilter.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import type * as SCHEMA from "@ctrlplane/db/schema"; -import type { ReleaseCondition } from "@ctrlplane/validators/releases"; -import React from "react"; -import Link from "next/link"; -import { useParams, useRouter } from "next/navigation"; -import { IconExternalLink, IconFilter, IconLoader2 } from "@tabler/icons-react"; -import LZString from "lz-string"; - -import { Button } from "@ctrlplane/ui/button"; -import { Label } from "@ctrlplane/ui/label"; -import { - defaultCondition, - isEmptyCondition, -} from "@ctrlplane/validators/releases"; - -import { api } from "~/trpc/react"; -import { ReleaseConditionBadge } from "../release-condition/ReleaseConditionBadge"; -import { ReleaseConditionDialog } from "../release-condition/ReleaseConditionDialog"; -import { ReleaseBadgeList } from "../ReleaseBadgeList"; - -type ReleaseFilterProps = { - releaseChannel: SCHEMA.ReleaseChannel; -}; - -const getFinalFilter = (filter?: ReleaseCondition) => - filter && !isEmptyCondition(filter) ? filter : undefined; - -const getReleaseFilterUrl = ( - workspaceSlug: string, - - systemSlug?: string, - deploymentSlug?: string, - filter?: ReleaseCondition, -) => { - if (filter == null || systemSlug == null || deploymentSlug == null) - return null; - const baseUrl = `/${workspaceSlug}/systems/${systemSlug}/deployments/${deploymentSlug}`; - const filterHash = LZString.compressToEncodedURIComponent( - JSON.stringify(filter), - ); - return `${baseUrl}/releases?filter=${filterHash}`; -}; - -export const ReleaseFilter: React.FC = ({ - releaseChannel, -}) => { - const { workspaceSlug, systemSlug, deploymentSlug } = useParams<{ - workspaceSlug: string; - systemSlug?: string; - deploymentSlug?: string; - }>(); - - const updateReleaseChannel = - api.deployment.releaseChannel.update.useMutation(); - const router = useRouter(); - const utils = api.useUtils(); - const { releaseFilter, deploymentId } = releaseChannel; - const filter = getFinalFilter(releaseFilter ?? undefined); - - const releasesQ = api.release.list.useQuery({ - deploymentId, - filter, - limit: 5, - }); - const releases = releasesQ.data; - - const onUpdate = (filter?: ReleaseCondition) => { - const releaseFilter = getFinalFilter(filter); - updateReleaseChannel - .mutateAsync({ id: releaseChannel.id, data: { releaseFilter } }) - .then(() => - utils.deployment.releaseChannel.byId.invalidate(releaseChannel.id), - ) - .then(() => router.refresh()); - }; - - const loading = releasesQ.isLoading; - - const releaseFilterUrl = getReleaseFilterUrl( - workspaceSlug, - systemSlug, - deploymentSlug, - filter, - ); - - if (loading) - return ( -
- -
- ); - - return ( -
- - {filter != null && } -
- - - - {releaseFilterUrl != null && ( - - - - )} -
- {releases != null && } -
- ); -}; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/release-channel-drawer/Usage.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/release-channel-drawer/Usage.tsx new file mode 100644 index 000000000..fb38bad4b --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/release-channel-drawer/Usage.tsx @@ -0,0 +1,105 @@ +import type * as SCHEMA from "@ctrlplane/db/schema"; +import { useRouter } from "next/navigation"; +import { IconFilter, IconPlant } from "@tabler/icons-react"; + +import { Button } from "@ctrlplane/ui/button"; + +import { EnvironmentDrawerTab } from "../environment-drawer/EnvironmentDrawer"; +import { EnvironmentPolicyDrawerTab } from "../environment-policy-drawer/EnvironmentPolicyDrawer"; + +type UsageInfo = { + environments: SCHEMA.Environment[]; + policies: (SCHEMA.EnvironmentPolicy & { + environments: SCHEMA.Environment[]; + })[]; +}; + +const useSetDrawers = () => { + const router = useRouter(); + + const setEnvironmentIdAndTab = (id: string) => { + const url = new URL(window.location.href); + url.searchParams.delete("release_channel_id"); + url.searchParams.set("environment_id", id); + url.searchParams.set("tab", EnvironmentDrawerTab.ReleaseChannels); + router.replace(`${url.pathname}?${url.searchParams.toString()}`); + }; + + const setPolicyIdAndTab = (id: string) => { + const url = new URL(window.location.href); + url.searchParams.delete("release_channel_id"); + url.searchParams.set("environment_policy_id", id); + url.searchParams.set("tab", EnvironmentPolicyDrawerTab.ReleaseChannels); + router.replace(`${url.pathname}?${url.searchParams.toString()}`); + }; + + return { setEnvironmentIdAndTab, setPolicyIdAndTab }; +}; + +export const Usage: React.FC<{ usage: UsageInfo }> = ({ usage }) => { + const { policies, environments } = usage; + + const inheritedEnvironments = policies + .flatMap((p) => p.environments.map((e) => ({ ...e, policyInherited: p }))) + .filter((env) => !environments.includes(env)); + + const { setEnvironmentIdAndTab, setPolicyIdAndTab } = useSetDrawers(); + + return ( +
+
+
+
+ +
+ Policies +
+
+ {policies.map((p) => ( + + ))} +
+
+ +
+
+
+ +
+ Environments +
+
+ {environments.map((e) => ( + + ))} + {inheritedEnvironments.map((e) => ( + + ))} +
+
+
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/DeploymentPageContent.tsx b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/DeploymentPageContent.tsx index 7ab5bae06..3e4b1e09c 100644 --- a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/DeploymentPageContent.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/releases/DeploymentPageContent.tsx @@ -20,6 +20,7 @@ import { TableRow, } from "@ctrlplane/ui/table"; +import { useReleaseChannelDrawer } from "~/app/[workspaceSlug]/_components/release-channel-drawer/useReleaseChannelDrawer"; import { ReleaseConditionBadge } from "~/app/[workspaceSlug]/_components/release-condition/ReleaseConditionBadge"; import { ReleaseConditionDialog } from "~/app/[workspaceSlug]/_components/release-condition/ReleaseConditionDialog"; import { useReleaseFilter } from "~/app/[workspaceSlug]/_components/release-condition/useReleaseFilter"; @@ -42,6 +43,7 @@ export const DeploymentPageContent: React.FC = ({ environments, }) => { const { filter, setFilter } = useReleaseFilter(); + const { setReleaseChannelId } = useReleaseChannelDrawer(); const { workspaceSlug, systemSlug } = useParams<{ workspaceSlug: string; @@ -204,9 +206,12 @@ export const DeploymentPageContent: React.FC = ({ const hasRelease = environmentReleaseReleaseJobTriggers.length > 0; const hasJobAgent = deployment.jobAgentId != null; - const isBlockedByReleaseChannel = ( - blockedEnvByRelease.data?.[release.id] ?? [] - ).includes(env.id); + const blockedEnv = blockedEnvByRelease.data?.find( + (be) => + be.releaseId === release.id && + be.environmentId === env.id, + ); + const isBlockedByReleaseChannel = blockedEnv != null; const showRelease = hasRelease; const canDeploy = @@ -254,11 +259,25 @@ export const DeploymentPageContent: React.FC = ({ {!canDeploy && !hasRelease && (
- {isBlockedByReleaseChannel - ? "Blocked by release channel" - : hasJobAgent - ? "No targets" - : "No job agent"} + {isBlockedByReleaseChannel ? ( + + Blocked by{" "} + + + ) : ( + "No job agent" + )}
)}
diff --git a/packages/api/src/router/deployment.ts b/packages/api/src/router/deployment.ts index f2d73ce5f..d014e3191 100644 --- a/packages/api/src/router/deployment.ts +++ b/packages/api/src/router/deployment.ts @@ -140,14 +140,41 @@ const releaseChannelRouter = createTRPCRouter({ .perform(Permission.ReleaseChannelGet) .on({ type: "releaseChannel", id: input }), }) - .query(({ ctx, input }) => - ctx.db + .query(async ({ ctx, input }) => { + const rc = await ctx.db.query.releaseChannel.findFirst({ + where: eq(releaseChannel.id, input), + with: { + environmentReleaseChannels: { with: { environment: true } }, + environmentPolicyReleaseChannels: { + with: { environmentPolicy: true }, + }, + }, + }); + if (rc == null) return null; + const policyIds = rc.environmentPolicyReleaseChannels.map( + (eprc) => eprc.environmentPolicy.id, + ); + + const envs = await ctx.db .select() - .from(releaseChannel) - .where(eq(releaseChannel.id, input)) - .orderBy(releaseChannel.name) - .then(takeFirst), - ), + .from(environment) + .where(inArray(environment.policyId, policyIds)); + + return { + ...rc, + usage: { + environments: rc.environmentReleaseChannels.map( + (erc) => erc.environment, + ), + policies: rc.environmentPolicyReleaseChannels.map((eprc) => ({ + ...eprc.environmentPolicy, + environments: envs.filter( + (e) => e.policyId === eprc.environmentPolicy.id, + ), + })), + }, + }; + }), }); export const deploymentRouter = createTRPCRouter({ diff --git a/packages/api/src/router/release.ts b/packages/api/src/router/release.ts index cfaf613e4..ae83f12e7 100644 --- a/packages/api/src/router/release.ts +++ b/packages/api/src/router/release.ts @@ -368,6 +368,7 @@ export const releaseRouter = createTRPCRouter({ .query(async ({ input }) => { const envRCSubquery = db .select({ + releaseChannelId: releaseChannel.id, releaseChannelEnvId: environmentReleaseChannel.environmentId, releaseChannelDeploymentId: releaseChannel.deploymentId, releaseChannelFilter: releaseChannel.releaseFilter, @@ -381,6 +382,7 @@ export const releaseRouter = createTRPCRouter({ const policyRCSubquery = db .select({ + releaseChannelId: releaseChannel.id, releaseChannelPolicyId: environmentPolicyReleaseChannel.policyId, releaseChannelDeploymentId: releaseChannel.deploymentId, releaseChannelFilter: releaseChannel.releaseFilter, @@ -449,6 +451,10 @@ export const releaseRouter = createTRPCRouter({ policyReleaseChannel?.releaseChannelFilter; if (releaseFilter == null) return null; + const releaseChannelId = + envReleaseChannel?.releaseChannelId ?? + policyReleaseChannel?.releaseChannelId; + const matchingRelease = await db .select() .from(release) @@ -461,22 +467,15 @@ export const releaseRouter = createTRPCRouter({ .then(takeFirstOrNull); return matchingRelease == null - ? { releaseId: rel.id, environmentId: environment.id } + ? { + releaseId: rel.id, + environmentId: environment.id, + releaseChannelId, + } : null; }); - const blockedEnvs = await Promise.all(blockedEnvsPromises).then((r) => - r.filter(isPresent), - ); - - return blockedEnvs.reduce( - (acc, { releaseId, environmentId }) => { - if (!acc[releaseId]) acc[releaseId] = []; - acc[releaseId].push(environmentId); - return acc; - }, - {} as Record, - ); + return Promise.all(blockedEnvsPromises).then((r) => r.filter(isPresent)); }), metadataKeys: createTRPCRouter({ diff --git a/packages/db/src/schema/environment.ts b/packages/db/src/schema/environment.ts index 338873889..55b539c57 100644 --- a/packages/db/src/schema/environment.ts +++ b/packages/db/src/schema/environment.ts @@ -56,7 +56,12 @@ export const createEnvironment = createInsertSchema(environment, { export const updateEnvironment = createEnvironment.partial(); export type InsertEnvironment = z.infer; -export const environmentRelations = relations(environment, ({ many }) => ({ +export const environmentRelations = relations(environment, ({ many, one }) => ({ + policy: one(environmentPolicy, { + fields: [environment.policyId], + references: [environmentPolicy.id], + }), + releaseChannels: many(environmentReleaseChannel), environments: many(variableSetEnvironment), })); @@ -116,6 +121,14 @@ export const createEnvironmentPolicy = createInsertSchema( export const updateEnvironmentPolicy = createEnvironmentPolicy.partial(); +export const environmentPolicyRelations = relations( + environmentPolicy, + ({ many }) => ({ + environmentPolicyReleaseChannels: many(environmentPolicyReleaseChannel), + environments: many(environment), + }), +); + export const recurrenceType = pgEnum("recurrence_type", [ "hourly", "daily", @@ -222,6 +235,20 @@ export type EnvironmentPolicyReleaseChannel = InferSelectModel< typeof environmentPolicyReleaseChannel >; +export const environmentPolicyReleaseChannelRelations = relations( + environmentPolicyReleaseChannel, + ({ one }) => ({ + environmentPolicy: one(environmentPolicy, { + fields: [environmentPolicyReleaseChannel.policyId], + references: [environmentPolicy.id], + }), + releaseChannel: one(releaseChannel, { + fields: [environmentPolicyReleaseChannel.channelId], + references: [releaseChannel.id], + }), + }), +); + export const environmentReleaseChannel = pgTable( "environment_release_channel", { @@ -246,6 +273,20 @@ export type EnvironmentReleaseChannel = InferSelectModel< typeof environmentReleaseChannel >; +export const environmentReleaseChannelRelations = relations( + environmentReleaseChannel, + ({ one }) => ({ + environment: one(environment, { + fields: [environmentReleaseChannel.environmentId], + references: [environment.id], + }), + releaseChannel: one(releaseChannel, { + fields: [environmentReleaseChannel.channelId], + references: [releaseChannel.id], + }), + }), +); + export const environmentMetadata = pgTable( "environment_metadata", { diff --git a/packages/db/src/schema/release.ts b/packages/db/src/schema/release.ts index 056997e6f..c994f20c5 100644 --- a/packages/db/src/schema/release.ts +++ b/packages/db/src/schema/release.ts @@ -17,6 +17,7 @@ import { not, notExists, or, + relations, sql, } from "drizzle-orm"; import { @@ -45,7 +46,11 @@ import { import type { Tx } from "../common.js"; import { user } from "./auth.js"; import { deployment } from "./deployment.js"; -import { environment } from "./environment.js"; +import { + environment, + environmentPolicyReleaseChannel, + environmentReleaseChannel, +} from "./environment.js"; import { job } from "./job.js"; import { target } from "./target.js"; @@ -67,6 +72,14 @@ export const createReleaseChannel = createInsertSchema(releaseChannel, { }).omit({ id: true }); export const updateReleaseChannel = createReleaseChannel.partial(); +export const releaseChannelRelations = relations( + releaseChannel, + ({ many }) => ({ + environmentReleaseChannels: many(environmentReleaseChannel), + environmentPolicyReleaseChannels: many(environmentPolicyReleaseChannel), + }), +); + export const releaseDependency = pgTable( "release_dependency", {