diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index 141444a4b2..7cdfb6d826 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -46,6 +46,12 @@ func Entitlements( return codersdk.Entitlements{}, xerrors.Errorf("query active user count: %w", err) } + // nolint:gocritic // Getting active AI seat count is a system function. + activeAISeatCount, err := db.GetActiveAISeatCount(dbauthz.AsSystemRestricted(ctx)) + if err != nil { + return codersdk.Entitlements{}, xerrors.Errorf("query active AI seat count: %w", err) + } + // nolint:gocritic // Getting external templates is a system function. externalTemplates, err := db.GetTemplatesWithFilter(dbauthz.AsSystemRestricted(ctx), database.GetTemplatesWithFilterParams{ HasExternalAgent: sql.NullBool{ @@ -59,6 +65,7 @@ func Entitlements( entitlements, err := LicensesEntitlements(ctx, now, licenses, enablements, keys, FeatureArguments{ ActiveUserCount: activeUserCount, + ActiveAISeatCount: activeAISeatCount, ReplicaCount: replicaCount, ExternalAuthCount: externalAuthCount, ExternalTemplateCount: int64(len(externalTemplates)), @@ -88,6 +95,7 @@ func Entitlements( type FeatureArguments struct { ActiveUserCount int64 + ActiveAISeatCount int64 ReplicaCount int ExternalAuthCount int ExternalTemplateCount int64 @@ -326,6 +334,9 @@ func LicensesEntitlements( if featureName == codersdk.FeatureUserLimit { actual = &featureArguments.ActiveUserCount } + if featureName == codersdk.FeatureAIGovernanceUserLimit { + actual = &featureArguments.ActiveAISeatCount + } entitlements.AddFeature(featureName, codersdk.Feature{ Enabled: true, @@ -478,6 +489,24 @@ func LicensesEntitlements( "Your deployment has %d active users but the license with the limit %d is expired.", featureArguments.ActiveUserCount, *userLimit.Limit)) } + if featureArguments.ActiveAISeatCount > 0 { + feature := entitlements.Features[codersdk.FeatureAIGovernanceUserLimit] + switch { + case feature.Entitlement == codersdk.EntitlementNotEntitled: + // If the limit is not set + entitlements.Errors = append(entitlements.Errors, + fmt.Sprintf("Your deployment has %d active AI governance seats but the license is not entitled to this feature.", featureArguments.ActiveAISeatCount)) + case feature.Entitlement == codersdk.EntitlementGracePeriod && feature.Limit != nil: + entitlements.Warnings = append(entitlements.Warnings, + fmt.Sprintf( + "Your deployment has %d active AI governance seats but the license with the limit %d is expired.", + featureArguments.ActiveAISeatCount, *feature.Limit)) + case feature.Limit != nil && featureArguments.ActiveAISeatCount > *feature.Limit: + entitlements.Warnings = append(entitlements.Warnings, fmt.Sprintf( + "Your deployment has %d active AI governance seats but is only licensed for %d.", + featureArguments.ActiveAISeatCount, *feature.Limit)) + } + } // Add a warning for every feature that is enabled but not entitled or // is in a grace period. @@ -486,6 +515,9 @@ func LicensesEntitlements( if featureName == codersdk.FeatureUserLimit { continue } + if featureName == codersdk.FeatureAIGovernanceUserLimit { + continue + } // High availability has it's own warnings based on replica count! if featureName == codersdk.FeatureHighAvailability { continue diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index e290036f5f..d23858b07a 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -851,6 +851,9 @@ func TestEntitlements(t *testing.T) { mDB.EXPECT(). GetActiveUserCount(gomock.Any(), false). Return(int64(1), nil) + mDB.EXPECT(). + GetActiveAISeatCount(gomock.Any()). + Return(int64(27), nil) mDB.EXPECT(). GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Cond(func(params database.GetTotalUsageDCManagedAgentsV1Params) bool { // gomock doesn't seem to compare times very nicely, so check @@ -885,10 +888,72 @@ func TestEntitlements(t *testing.T) { require.NotNil(t, managedAgentLimit.Actual) require.EqualValues(t, 175, *managedAgentLimit.Actual) + aiGovernanceSeatLimit, ok := entitlements.Features[codersdk.FeatureAIGovernanceUserLimit] + require.True(t, ok) + require.NotNil(t, aiGovernanceSeatLimit.Actual) + require.EqualValues(t, 27, *aiGovernanceSeatLimit.Actual) + require.NotNil(t, aiGovernanceSeatLimit.Limit) + require.EqualValues(t, 100, *aiGovernanceSeatLimit.Limit) + // Usage exceeds the limit, so an exceeded warning should be present. require.Len(t, entitlements.Warnings, 1) require.Equal(t, codersdk.LicenseManagedAgentLimitExceededWarningText, entitlements.Warnings[0]) }) + + t.Run("AIGovernanceSeatLimitExceededWarning", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + mDB := dbmock.NewMockStore(ctrl) + + licenseOpts := (&coderdenttest.LicenseOptions{ + FeatureSet: codersdk.FeatureSetPremium, + NotBefore: dbtime.Now().Add(-time.Hour).Truncate(time.Second), + GraceAt: dbtime.Now().Add(time.Hour * 24 * 60).Truncate(time.Second), + ExpiresAt: dbtime.Now().Add(time.Hour * 24 * 90).Truncate(time.Second), + Addons: []codersdk.Addon{codersdk.AddonAIGovernance}, + Features: license.Features{ + codersdk.FeatureAIGovernanceUserLimit: 100, + }, + }). + UserLimit(100) + + lic := database.License{ + ID: 1, + JWT: coderdenttest.GenerateLicense(t, *licenseOpts), + Exp: licenseOpts.ExpiresAt, + } + + mDB.EXPECT(). + GetUnexpiredLicenses(gomock.Any()). + Return([]database.License{lic}, nil) + mDB.EXPECT(). + GetActiveUserCount(gomock.Any(), false). + Return(int64(1), nil) + mDB.EXPECT(). + GetActiveAISeatCount(gomock.Any()). + Return(int64(127), nil) + mDB.EXPECT(). + GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()). + Return(int64(0), nil) + mDB.EXPECT(). + GetTemplatesWithFilter(gomock.Any(), gomock.Any()). + Return([]database.Template{}, nil) + + entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, all) + require.NoError(t, err) + require.True(t, entitlements.HasLicense) + + aiGovernanceSeatLimit, ok := entitlements.Features[codersdk.FeatureAIGovernanceUserLimit] + require.True(t, ok) + require.NotNil(t, aiGovernanceSeatLimit.Actual) + require.EqualValues(t, 127, *aiGovernanceSeatLimit.Actual) + require.NotNil(t, aiGovernanceSeatLimit.Limit) + require.EqualValues(t, 100, *aiGovernanceSeatLimit.Limit) + + require.Len(t, entitlements.Warnings, 1) + require.Equal(t, "Your deployment has 127 active AI governance seats but is only licensed for 100.", entitlements.Warnings[0]) + }) } func TestLicenseEntitlements(t *testing.T) { diff --git a/site/src/api/api.ts b/site/src/api/api.ts index b5ba39a16d..dad63e9498 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -425,6 +425,7 @@ type Claims = { all_features: boolean; // feature_set is omitted on legacy licenses feature_set?: string; + addons?: string[]; version: number; features: Record; require_telemetry?: boolean; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceAddOnCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceAddOnCard.stories.tsx new file mode 100644 index 0000000000..b2c138d2dd --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceAddOnCard.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AIGovernanceAddOnCard } from "./AIGovernanceAddOnCard"; + +const meta: Meta = { + title: + "pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceAddOnCard", + component: AIGovernanceAddOnCard, + args: { + title: "AI governance", + unit: "Seats", + actual: 750, + limit: 1000, + isExceeded: false, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Exceeded: Story = { + args: { + actual: 1200, + limit: 1000, + isExceeded: true, + }, +}; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceAddOnCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceAddOnCard.tsx new file mode 100644 index 0000000000..c284327324 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceAddOnCard.tsx @@ -0,0 +1,81 @@ +import { Badge } from "components/Badge/Badge"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { InfoIcon } from "lucide-react"; +import type { FC } from "react"; + +type AIGovernanceAddOnCardProps = { + title: string; + unit: string; + actual?: number; + limit: number; + isExceeded: boolean; +}; + +export const AIGovernanceAddOnCard: FC = ({ + title, + unit, + actual, + limit, + isExceeded, +}) => { + const actualLabel = actual === undefined ? "—" : actual.toLocaleString(); + + return ( +
+
+
+
+
+ + {title} + + + + + + + Seats consumed by users using AI Governance features. + + +
+ + AI add-on + +
+ +
+
+
{unit}
+
+ + {actualLabel} + {" "} + / {limit.toLocaleString()} +
+
+
+
+
+
+ ); +}; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceUsersConsumptionChart.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceUsersConsumptionChart.stories.tsx index 23a62a047c..70e12fda6a 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceUsersConsumptionChart.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceUsersConsumptionChart.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; import { AIGovernanceUsersConsumption } from "./AIGovernanceUsersConsumptionChart"; const meta: Meta = { @@ -10,6 +11,7 @@ const meta: Meta = { enabled: true, entitlement: "entitled", limit: 1000, + actual: 750, }, }, }; @@ -19,6 +21,25 @@ type Story = StoryObj; export const Default: Story = {}; +export const Exceeded: Story = { + args: { + aiGovernanceUserFeature: { + enabled: true, + entitlement: "entitled", + limit: 1000, + actual: 1200, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("1,200")).toBeInTheDocument(); + await expect( + canvas.getByText(/of 1,000 Users Entitled/i), + ).toBeInTheDocument(); + await expect(canvas.getByText("Add-on exceeded")).toBeInTheDocument(); + }, +}; + export const Disabled: Story = { args: { aiGovernanceUserFeature: { diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceUsersConsumptionChart.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceUsersConsumptionChart.tsx index a5610550d9..68c3cd704f 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceUsersConsumptionChart.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceUsersConsumptionChart.tsx @@ -9,6 +9,7 @@ import { import { Link } from "components/Link/Link"; import { ChevronRightIcon } from "lucide-react"; import type { FC } from "react"; +import { cn } from "utils/cn"; import { docs } from "utils/docs"; interface AIGovernanceUsersConsumptionProps { @@ -42,8 +43,15 @@ export const AIGovernanceUsersConsumption: FC< return ; } + const actual = aiGovernanceUserFeature.actual; + const isExceeded = actual !== undefined && actual > limit; + return ( -
+
@@ -105,13 +113,44 @@ export const AIGovernanceUsersConsumption: FC<
-
-
{limit.toLocaleString()}
-
Users Entitled
-
-
- Additional analytics and measurements coming soon -
+ {actual !== undefined ? ( + <> +
+
+ {actual.toLocaleString()} +
+
+ of {limit.toLocaleString()} Users Entitled +
+
+
+ {isExceeded ? "Add-on exceeded" : "Active"} +
+ + ) : ( + <> +
+
+ {limit.toLocaleString()} +
+
+ Users Entitled +
+
+
+ Additional analytics and measurements coming soon +
+ + )}
diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx new file mode 100644 index 0000000000..997eaa7fb9 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx @@ -0,0 +1,366 @@ +import { chromatic } from "testHelpers/chromatic"; +import { MockLicenseResponse } from "testHelpers/entities"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import dayjs from "dayjs"; +import { expect, fn, within } from "storybook/test"; + +import { LicenseCard } from "./LicenseCard"; + +const meta: Meta = { + title: "pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard", + component: LicenseCard, + parameters: { chromatic }, + args: { + license: MockLicenseResponse[0], + userLimitActual: 4, + userLimitLimit: 10, + onRemove: fn(), + isRemoving: false, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("#1")).toBeInTheDocument(); + await expect(canvas.getByText("4 / 10")).toBeInTheDocument(); + await expect(canvas.getByText("Enterprise")).toBeInTheDocument(); + }, +}; + +export const UnlimitedUsers: Story = { + args: { + userLimitLimit: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("4 / Unlimited")).toBeInTheDocument(); + }, +}; + +export const UsesLicenseUserLimit: Story = { + args: { + license: { + ...MockLicenseResponse[0], + claims: { + ...MockLicenseResponse[0].claims, + features: { + ...MockLicenseResponse[0].claims.features, + user_limit: 3, + }, + }, + }, + userLimitActual: 1, + userLimitLimit: 100, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("1 / 3")).toBeInTheDocument(); + }, +}; + +export const Premium: Story = { + args: { + license: MockLicenseResponse[1], + }, +}; + +export const PremiumWithAIGovernance: Story = { + args: { + license: { + ...MockLicenseResponse[1], + claims: { + ...MockLicenseResponse[1].claims, + features: { + ...MockLicenseResponse[1].claims.features, + ai_governance_user_limit: 1000, + }, + addons: ["ai_governance"], + }, + }, + aiGovernanceUserFeature: { + enabled: true, + entitlement: "entitled", + actual: 750, + limit: 1000, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/add-ons/i)).toBeInTheDocument(); + await expect(canvas.getByText(/ai governance/i)).toBeInTheDocument(); + const seatsLabel = canvas.getByText("Seats"); + const seatsValue = seatsLabel.nextElementSibling; + await expect(seatsValue).toHaveTextContent("750 / 1,000"); + }, +}; + +export const PremiumWithoutAIGovernanceAddOn: Story = { + args: { + license: { + ...MockLicenseResponse[1], + claims: { + ...MockLicenseResponse[1].claims, + features: { + ...MockLicenseResponse[1].claims.features, + ai_governance_user_limit: 1000, + }, + }, + }, + aiGovernanceUserFeature: { + enabled: true, + entitlement: "entitled", + actual: 100, + limit: 1000, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.queryByText("Add-ons")).not.toBeInTheDocument(); + await expect(canvas.queryByText("AI governance")).not.toBeInTheDocument(); + }, +}; + +export const Expired: Story = { + args: { + license: MockLicenseResponse[3], + }, +}; + +export const ExceededUserLimit: Story = { + args: { + userLimitActual: 15, + userLimitLimit: 10, + }, +}; + +export const ExceededAIGovernance: Story = { + args: { + license: { + ...MockLicenseResponse[1], + claims: { + ...MockLicenseResponse[1].claims, + features: { + ...MockLicenseResponse[1].claims.features, + ai_governance_user_limit: 1000, + }, + addons: ["ai_governance"], + }, + }, + aiGovernanceUserFeature: { + enabled: true, + entitlement: "entitled", + actual: 1200, + limit: 1000, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Add-on exceeded")).toBeInTheDocument(); + const seatsLabel = canvas.getByText("Seats"); + const seatsValue = seatsLabel.nextElementSibling; + await expect(seatsValue).toHaveTextContent("1,200 / 1,000"); + }, +}; + +export const ExpiredAIGovernanceOverageShowsExpired: Story = { + args: { + license: { + ...MockLicenseResponse[1], + claims: { + ...MockLicenseResponse[1].claims, + license_expires: dayjs().subtract(1, "day").unix(), + features: { + ...MockLicenseResponse[1].claims.features, + ai_governance_user_limit: 1000, + }, + addons: ["ai_governance"], + }, + }, + aiGovernanceUserFeature: { + enabled: true, + entitlement: "entitled", + actual: 1200, + limit: 1000, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Expired")).toBeInTheDocument(); + await expect(canvas.queryByText("Add-on exceeded")).not.toBeInTheDocument(); + const seatsLabel = canvas.getByText("Seats"); + const seatsValue = seatsLabel.nextElementSibling; + await expect(seatsValue).toHaveTextContent("—"); + await expect(seatsValue).toHaveTextContent("/ 1,000"); + }, +}; + +export const ExpiredAIGovernanceInGracePeriodShowsExceeded: Story = { + args: { + license: { + ...MockLicenseResponse[1], + claims: { + ...MockLicenseResponse[1].claims, + license_expires: dayjs().subtract(1, "day").unix(), + features: { + ...MockLicenseResponse[1].claims.features, + ai_governance_user_limit: 1000, + }, + addons: ["ai_governance"], + }, + }, + aiGovernanceUserFeature: { + enabled: true, + entitlement: "grace_period", + actual: 1200, + limit: 1000, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Add-on exceeded")).toBeInTheDocument(); + const seatsLabel = canvas.getByText("Seats"); + const seatsValue = seatsLabel.nextElementSibling; + await expect(seatsValue).toHaveTextContent("1,200 / 1,000"); + }, +}; + +export const NotYetValid: Story = { + args: { + license: { + ...MockLicenseResponse[1], + claims: { + ...MockLicenseResponse[1].claims, + nbf: dayjs().add(7, "day").unix(), + }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/Not started/)).toBeInTheDocument(); + }, +}; + +export const FutureAIGovernanceOverageShowsStartsOn: Story = { + args: { + license: { + ...MockLicenseResponse[1], + claims: { + ...MockLicenseResponse[1].claims, + nbf: dayjs().add(7, "day").unix(), + features: { + ...MockLicenseResponse[1].claims.features, + ai_governance_user_limit: 1000, + }, + addons: ["ai_governance"], + }, + }, + aiGovernanceUserFeature: { + enabled: true, + entitlement: "entitled", + actual: 1200, + limit: 1000, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/Not started/)).toBeInTheDocument(); + await expect(canvas.queryByText("Add-on exceeded")).not.toBeInTheDocument(); + const seatsLabel = canvas.getByText("Seats"); + const seatsValue = seatsLabel.nextElementSibling; + await expect(seatsValue).toHaveTextContent("—"); + await expect(seatsValue).toHaveTextContent("/ 1,000"); + }, +}; + +export const FutureAIGovernanceUsageShowsNoCurrentSeats: Story = { + args: { + license: { + ...MockLicenseResponse[1], + claims: { + ...MockLicenseResponse[1].claims, + nbf: dayjs().add(7, "day").unix(), + features: { + ...MockLicenseResponse[1].claims.features, + ai_governance_user_limit: 1000, + }, + addons: ["ai_governance"], + }, + }, + aiGovernanceUserFeature: { + enabled: true, + entitlement: "entitled", + limit: 1000, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const seatsLabel = canvas.getByText("Seats"); + const seatsValue = seatsLabel.nextElementSibling; + await expect(seatsValue).toHaveTextContent("—"); + await expect(seatsValue).toHaveTextContent("/ 1,000"); + await expect(seatsValue).not.toHaveTextContent("0 / 1,000"); + }, +}; + +export const LowerLimitCardUsesMergedEntitlement: Story = { + args: { + license: { + ...MockLicenseResponse[1], + claims: { + ...MockLicenseResponse[1].claims, + features: { + ...MockLicenseResponse[1].claims.features, + ai_governance_user_limit: 500, + }, + addons: ["ai_governance"], + }, + }, + aiGovernanceUserFeature: { + enabled: true, + entitlement: "entitled", + actual: 750, + limit: 1000, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const seatsLabel = canvas.getByText("Seats"); + const seatsValue = seatsLabel.nextElementSibling; + await expect(seatsValue).toHaveTextContent("—"); + await expect(seatsValue).toHaveTextContent("/ 500"); + await expect(seatsValue).not.toHaveTextContent("750 / 500"); + await expect(canvas.queryByText("Add-on exceeded")).not.toBeInTheDocument(); + }, +}; + +export const EnterpriseDoesNotShowAIGovernanceAddOn: Story = { + args: { + license: { + ...MockLicenseResponse[1], + claims: { + ...MockLicenseResponse[1].claims, + features: { + ...MockLicenseResponse[1].claims.features, + ai_governance_user_limit: 1000, + }, + feature_set: "enterprise", + addons: ["ai_governance"], + }, + }, + aiGovernanceUserFeature: { + enabled: true, + entitlement: "entitled", + actual: 750, + limit: 1000, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.queryByText("Add-ons")).not.toBeInTheDocument(); + await expect(canvas.queryByText("AI add-on")).not.toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.test.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.test.tsx index 294ec59a41..93e7a2a05a 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.test.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.test.tsx @@ -1,46 +1,17 @@ import { MockLicenseResponse } from "testHelpers/entities"; import { render } from "testHelpers/renderHelpers"; import { screen, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import userEvent, { type UserEvent } from "@testing-library/user-event"; import { LicenseCard } from "./LicenseCard"; +const openRemoveDialog = async (user: UserEvent) => { + await user.click( + screen.getByRole("button", { name: /show license actions/i }), + ); + await user.click(await screen.findByRole("menuitem", { name: /remove/i })); +}; + describe("LicenseCard", () => { - it("renders (smoke test)", async () => { - // When - render( - null} - isRemoving={false} - />, - ); - - // Then - await screen.findByText("#1"); - await screen.findByText("1 / 10"); - await screen.findByText("Enterprise"); - }); - - it("renders userLimit as unlimited if there is not user limit", async () => { - // When - render( - null} - isRemoving={false} - />, - ); - - // Then - await screen.findByText("#1"); - await screen.findByText("1 / Unlimited"); - await screen.findByText("Enterprise"); - }); - it("shows expired removal message for expired licenses", async () => { const user = userEvent.setup(); render( @@ -53,8 +24,7 @@ describe("LicenseCard", () => { />, ); - const removeButton = await screen.findByRole("button", { name: /remove/i }); - await user.click(removeButton); + await openRemoveDialog(user); const dialog = await screen.findByTestId("dialog"); expect(dialog).toHaveTextContent(/This license has already expired/); @@ -72,42 +42,13 @@ describe("LicenseCard", () => { />, ); - const removeButton = await screen.findByRole("button", { name: /remove/i }); - await user.click(removeButton); + await openRemoveDialog(user); await screen.findByText( /Removing this license will disable all Premium features/, ); }); - it("renders license's user_limit when it is available instead of using the default", async () => { - const licenseUserLimit = 3; - const license = { - ...MockLicenseResponse[0], - claims: { - ...MockLicenseResponse[0].claims, - features: { - ...MockLicenseResponse[0].claims.features, - user_limit: licenseUserLimit, - }, - }, - }; - - // When - render( - null} - isRemoving={false} - />, - ); - - // Then - await screen.findByText("1 / 3"); - }); - it("requires typing the license ID before allowing removal", async () => { const user = userEvent.setup(); const onRemove = vi.fn(); @@ -123,7 +64,7 @@ describe("LicenseCard", () => { />, ); - await user.click(screen.getByRole("button", { name: /remove/i })); + await openRemoveDialog(user); const dialog = await screen.findByTestId("dialog"); const dialogScope = within(dialog); diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx index 169fe25328..b99c6a66ad 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx @@ -1,12 +1,27 @@ import type { GetLicensesResponse } from "api/api"; +import type { Feature } from "api/typesGenerated"; import { Button } from "components/Button/Button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "components/Collapsible/Collapsible"; import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"; -import { Pill } from "components/Pill/Pill"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "components/DropdownMenu/DropdownMenu"; import dayjs from "dayjs"; +import { ChevronDownIcon, EllipsisVerticalIcon, TrashIcon } from "lucide-react"; import { type FC, useState } from "react"; +import { cn } from "utils/cn"; +import { AIGovernanceAddOnCard } from "./AIGovernanceAddOnCard"; type LicenseCardProps = { license: GetLicensesResponse; + aiGovernanceUserFeature?: Feature; userLimitActual?: number; userLimitLimit?: number; onRemove: (licenseId: number) => void; @@ -15,6 +30,7 @@ type LicenseCardProps = { export const LicenseCard: FC = ({ license, + aiGovernanceUserFeature, userLimitActual, userLimitLimit, onRemove, @@ -24,24 +40,117 @@ export const LicenseCard: FC = ({ number | undefined >(undefined); - const currentUserLimit = license.claims.features.user_limit || userLimitLimit; + const currentUserLimit = license.claims.features.user_limit ?? userLimitLimit; const confirmationName = licenseIDMarkedForRemoval?.toString() ?? ""; const isExpired = dayjs .unix(license.claims.license_expires) .isBefore(dayjs()); + const isNotYetValid = + license.claims.nbf !== undefined && + dayjs.unix(license.claims.nbf).isAfter(dayjs()); + const isPremium = license.claims.feature_set?.toLowerCase() === "premium"; + const aiGovernanceActual = aiGovernanceUserFeature?.actual; + const aiGovernanceMergedLimit = aiGovernanceUserFeature?.limit; + const aiGovernanceLimit = + license.claims.features?.ai_governance_user_limit ?? 0; const licenseType = license.claims.trial ? "Trial" - : license.claims.feature_set?.toLowerCase() === "premium" + : isPremium ? "Premium" : "Enterprise"; + const hasExplicitAiGovernanceAddOn = (license.claims.addons ?? []).includes( + "ai_governance", + ); + const isAiGovernanceEntitlementInGracePeriod = + aiGovernanceUserFeature?.entitlement === "grace_period"; + // Overage/display checks only apply to licenses that are currently effective. + const isLicenseApplicableForAiGovernanceOverage = + !isNotYetValid && (!isExpired || isAiGovernanceEntitlementInGracePeriod); + // A license "wins" when its AI governance limit matches the merged limit. + const isWinningAiGovernanceLicense = + aiGovernanceMergedLimit !== undefined && + aiGovernanceLimit > 0 && + aiGovernanceLimit === aiGovernanceMergedLimit; + const canUseAiGovernanceUsageForThisLicense = + isLicenseApplicableForAiGovernanceOverage && + hasExplicitAiGovernanceAddOn && + isWinningAiGovernanceLicense; + // Show the add-on as exceeded only for the winning, active add-on license. + const isAiGovernanceAddOnExceeded = + canUseAiGovernanceUsageForThisLicense && + aiGovernanceActual !== undefined && + aiGovernanceActual > aiGovernanceLimit; + // Show actual usage only when this license is the one providing the limit. + const aiGovernanceDisplayActual = canUseAiGovernanceUsageForThisLicense + ? aiGovernanceActual + : undefined; + const statusClassName = + isAiGovernanceAddOnExceeded || isExpired + ? "text-content-destructive" + : isNotYetValid + ? "text-content-warning" + : "text-content-success"; + const statusText = isAiGovernanceAddOnExceeded + ? "Add-on exceeded" + : isExpired + ? "Expired" + : isNotYetValid + ? "Not started" + : "Active"; + const hasCollapsibleContent = isPremium && hasExplicitAiGovernanceAddOn; + const headerContent = ( + <> +
+ {hasCollapsibleContent && ( + + )} + + #{license.id} + + + {licenseType} + +
+ +
+
+ Status + {statusText} +
+
+ Users + + {userLimitActual} {` / ${currentUserLimit || "Unlimited"}`} + +
+ {license.claims.nbf && ( +
+ Valid From + + {dayjs.unix(license.claims.nbf).format("MMMM D, YYYY")} + +
+ )} +
+ Valid Until + + {dayjs.unix(license.claims.license_expires).format("MMMM D, YYYY")} + +
+
+ + ); + return ( -
+ = ({ } confirmLoading={isRemoving} /> -
- - #{license.id} - - - {licenseType} - -
-
- Users - - {userLimitActual} {` / ${currentUserLimit || "Unlimited"}`} - -
- {license.claims.nbf && ( -
- Valid From - - {dayjs.unix(license.claims.nbf).format("MMMM D, YYYY")} - +
+
+ {hasCollapsibleContent ? ( + + + + ) : ( +
+ {headerContent}
)} -
- {isExpired ? ( - - Expired - - ) : ( - Valid Until - )} - - {dayjs - .unix(license.claims.license_expires) - .format("MMMM D, YYYY")} - -
- + + + + + + + setLicenseIDMarkedForRemoval(license.id)} + > + + Remove… + + +
+ + + {hasCollapsibleContent && ( +
+
+ Add-ons +
+
+ +
+
+ )} +
-
+ ); }; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPage.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPage.stories.tsx index c70c7a77bd..95e4abeb6d 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPage.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPage.stories.tsx @@ -1,5 +1,7 @@ -import { MockLicenseResponse } from "testHelpers/entities"; +import { MockEntitlements, MockLicenseResponse } from "testHelpers/entities"; import type { Meta, StoryObj } from "@storybook/react-vite"; +import dayjs from "dayjs"; +import { expect, within } from "storybook/test"; import LicensesSettingsPage from "./LicensesSettingsPage"; const meta: Meta = { @@ -16,12 +18,152 @@ const meta: Meta = { export default meta; type Story = StoryObj; +const USER_STATUS_COUNTS_QUERY = { + key: ["insights", "userStatusCounts"], + data: { active: [] }, +}; + +const withBaseQueries = ({ + entitlements = MockEntitlements, + licenses = MockLicenseResponse, +}: { + entitlements?: typeof MockEntitlements; + licenses?: typeof MockLicenseResponse | unknown[]; +}) => ({ + queries: [ + { key: ["entitlements"], data: entitlements }, + { key: ["licenses"], data: licenses }, + USER_STATUS_COUNTS_QUERY, + ], +}); + +const createEntitlements = ({ + userLimit, + aiGovernanceUserLimit, +}: { + userLimit: { + enabled: boolean; + entitlement: "entitled" | "not_entitled"; + actual: number; + limit?: number; + }; + aiGovernanceUserLimit?: { + enabled: boolean; + entitlement: "entitled" | "not_entitled" | "grace_period"; + actual: number; + limit: number; + }; +}) => ({ + ...MockEntitlements, + has_license: true, + features: { + ...MockEntitlements.features, + user_limit: userLimit, + ai_governance_user_limit: + aiGovernanceUserLimit ?? + MockEntitlements.features.ai_governance_user_limit, + }, +}); + +const createLicense = ({ + id, + uuid, + featureSet, + uploadedDaysAgo, + expiresInDays, + licenseExpiresInDays, + nbfOffsetDays, + aiGovernanceUserLimit, + userLimit, + addons, +}: { + id: number; + uuid: string; + featureSet: "PREMIUM" | "enterprise"; + uploadedDaysAgo: number; + expiresInDays: number; + licenseExpiresInDays: number; + nbfOffsetDays: number; + aiGovernanceUserLimit: number; + userLimit: number; + addons?: string[]; +}) => ({ + id, + uploaded_at: String(dayjs().subtract(uploadedDaysAgo, "day").unix()), + expires_at: String(dayjs().add(expiresInDays, "day").unix()), + uuid, + claims: { + trial: false, + all_features: true, + feature_set: featureSet, + version: 1, + features: { + ai_governance_user_limit: aiGovernanceUserLimit, + user_limit: userLimit, + }, + addons, + license_expires: dayjs().add(licenseExpiresInDays, "day").unix(), + nbf: dayjs().add(nbfOffsetDays, "day").unix(), + }, +}); + export const WithoutUserLimitFeature: Story = { parameters: { - queries: [ - { key: ["entitlements"], data: { features: {} } }, - { key: ["licenses"], data: MockLicenseResponse }, - { key: ["insights", "userStatusCounts"], data: { active: [] } }, - ], + ...withBaseQueries({ + entitlements: { + ...MockEntitlements, + features: { + ...MockEntitlements.features, + user_limit: { + enabled: false, + entitlement: "not_entitled", + actual: 4, + }, + }, + }, + }), + }, +}; + +export const ShowsAddonUiForFutureLicenseBeforeNbf: Story = { + parameters: { + ...withBaseQueries({ + entitlements: createEntitlements({ + userLimit: { + enabled: true, + entitlement: "entitled", + actual: 3, + limit: 10, + }, + aiGovernanceUserLimit: { + enabled: false, + entitlement: "not_entitled", + actual: 0, + limit: 0, + }, + }), + licenses: [ + createLicense({ + id: 44, + uuid: "future-premium-addon-license", + featureSet: "PREMIUM", + uploadedDaysAgo: 0, + expiresInDays: 365, + licenseExpiresInDays: 365, + nbfOffsetDays: 7, + aiGovernanceUserLimit: 100, + userLimit: 10, + addons: ["ai_governance"], + }), + ], + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/add-ons/i)).toBeInTheDocument(); + const aiGovernanceTitles = canvas.getAllByText(/^ai governance$/i); + await expect(aiGovernanceTitles.length).toBeGreaterThan(0); + await expect(canvas.getByText(/not started/i)).toBeInTheDocument(); + await expect(canvas.getByText(/valid from/i)).toBeInTheDocument(); }, }; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx index 4444d2cdee..6c7d3401f3 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx @@ -127,6 +127,7 @@ const LicensesSettingsPageView: FC = ({ license={license} userLimitActual={userLimitActual} userLimitLimit={userLimitLimit} + aiGovernanceUserFeature={aiGovernanceUserFeature} isRemoving={isRemovingLicense} onRemove={removeLicense} />