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 (
);
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.map((p) => (
+
+ ))}
+
+
+
+
+
+
+ {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",
{