mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: add AI Governance seat capacity banners (#23411)
## Summary
Add site-wide banners for AI Governance seat usage thresholds:
1. **90% capacity warning (admin-only):** When actual AI Governance
seats are ≥90% and <100% of the license limit, admins see:
> "You have used 90% of your AI governance add-on seats."
2. **Over-limit banner (admin-only):** When actual seats exceed the
license limit, admins see a prominent warning:
> "Your organization is using {actual} / {limit} AI Governance user
seats ({X}% over the limit). Contact sales@coder.com"
- Uses floor whole percentage (Go int division / `Math.floor`)
- Includes a clickable `mailto:sales@coder.com` link
This commit is contained in:
@@ -15,6 +15,8 @@ const (
|
||||
LicenseExpiryClaim = "license_expires"
|
||||
LicenseTelemetryRequiredErrorText = "License requires telemetry but telemetry is disabled"
|
||||
LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows."
|
||||
LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats."
|
||||
LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."
|
||||
)
|
||||
|
||||
type AddLicenseRequest struct {
|
||||
|
||||
@@ -81,7 +81,7 @@ rates, and usage patterns to inform decisions about AI strategy.
|
||||
Starting with Coder v2.30 (February 2026), AI Bridge and Agent Boundaries are
|
||||
generally available as part of the AI Governance Add-On.
|
||||
|
||||
The AI Governance Add-On is required to use AI Bridge and Agent Boundaries.
|
||||
The AI Governance add-on is required to use AI Bridge and Agent Boundaries.
|
||||
If your deployment does not have the add-on, you'll see a notification banner
|
||||
reminding you to enable it.
|
||||
|
||||
|
||||
@@ -490,21 +490,31 @@ func LicensesEntitlements(
|
||||
featureArguments.ActiveUserCount, *userLimit.Limit))
|
||||
}
|
||||
if featureArguments.ActiveAISeatCount > 0 {
|
||||
actual := featureArguments.ActiveAISeatCount
|
||||
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))
|
||||
fmt.Sprintf("Your deployment has %d active AI Governance seats but the license is not entitled to this feature.", actual))
|
||||
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))
|
||||
actual, *feature.Limit))
|
||||
// Also emit seat-capacity warnings during grace period so admins
|
||||
// see both expiry and usage details.
|
||||
entitlements.Warnings = appendAIGovernanceSeatLimitWarning(
|
||||
entitlements.Warnings,
|
||||
actual,
|
||||
*feature.Limit,
|
||||
)
|
||||
case feature.Limit != nil:
|
||||
entitlements.Warnings = appendAIGovernanceSeatLimitWarning(
|
||||
entitlements.Warnings,
|
||||
actual,
|
||||
*feature.Limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,7 +565,7 @@ func LicensesEntitlements(
|
||||
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
|
||||
if aiBridgeFeature.Enabled && aiBridgeFeature.Entitlement.Entitled() && !hasExplicitAIBridgeEntitlement {
|
||||
entitlements.Warnings = append(entitlements.Warnings,
|
||||
"The AI Governance Add-On is required to use AI Bridge. Please reach out to your account team or sales@coder.com to learn more.")
|
||||
"The AI Governance add-on is required to use AI Bridge. Please reach out to your account team or sales@coder.com to learn more.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,6 +582,27 @@ func LicensesEntitlements(
|
||||
return entitlements, nil
|
||||
}
|
||||
|
||||
func appendAIGovernanceSeatLimitWarning(warnings []string, actual int64, limit int64) []string {
|
||||
if limit <= 0 {
|
||||
return warnings
|
||||
}
|
||||
|
||||
if actual > limit {
|
||||
overLimitSeats := actual - limit
|
||||
return append(warnings, fmt.Sprintf(
|
||||
codersdk.LicenseAIGovernanceOverLimitWarningText,
|
||||
actual,
|
||||
limit,
|
||||
overLimitSeats,
|
||||
))
|
||||
} else if actual*10 >= limit*9 {
|
||||
usedPercent := (actual * 100) / limit
|
||||
return append(warnings, fmt.Sprintf(codersdk.LicenseAIGovernance90PercentWarningText, usedPercent))
|
||||
}
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
const (
|
||||
CurrentVersion = 3
|
||||
HeaderKeyID = "kid"
|
||||
|
||||
@@ -900,59 +900,252 @@ func TestEntitlements(t *testing.T) {
|
||||
require.Equal(t, codersdk.LicenseManagedAgentLimitExceededWarningText, entitlements.Warnings[0])
|
||||
})
|
||||
|
||||
t.Run("AIGovernanceSeatLimitExceededWarning", func(t *testing.T) {
|
||||
t.Run("AIGovernanceSeatWarnings", 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,
|
||||
testCases := []struct {
|
||||
name string
|
||||
limit int64
|
||||
activeSeatCount int64
|
||||
expectedWarning string
|
||||
}{
|
||||
{
|
||||
name: "At90Percent",
|
||||
limit: 100,
|
||||
activeSeatCount: 90,
|
||||
expectedWarning: fmt.Sprintf(codersdk.LicenseAIGovernance90PercentWarningText, 90),
|
||||
},
|
||||
{
|
||||
name: "Below90Percent",
|
||||
limit: 100,
|
||||
activeSeatCount: 89,
|
||||
},
|
||||
{
|
||||
name: "OverLimit",
|
||||
limit: 100,
|
||||
activeSeatCount: 110,
|
||||
expectedWarning: fmt.Sprintf(codersdk.LicenseAIGovernanceOverLimitWarningText, 110, 100, 10),
|
||||
},
|
||||
{
|
||||
name: "AtLimit",
|
||||
limit: 100,
|
||||
activeSeatCount: 100,
|
||||
expectedWarning: fmt.Sprintf(codersdk.LicenseAIGovernance90PercentWarningText, 100),
|
||||
},
|
||||
{
|
||||
name: "OverLimitRoundingDown",
|
||||
limit: 101,
|
||||
activeSeatCount: 106,
|
||||
expectedWarning: fmt.Sprintf(codersdk.LicenseAIGovernanceOverLimitWarningText, 106, 101, 5),
|
||||
},
|
||||
{
|
||||
name: "TinyOverage",
|
||||
limit: 1000,
|
||||
activeSeatCount: 1001,
|
||||
expectedWarning: fmt.Sprintf(codersdk.LicenseAIGovernanceOverLimitWarningText, 1001, 1000, 1),
|
||||
},
|
||||
{
|
||||
name: "ZeroLimitGuard",
|
||||
limit: 0,
|
||||
activeSeatCount: 5,
|
||||
},
|
||||
}).
|
||||
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)
|
||||
for _, tc := range testCases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, all)
|
||||
require.NoError(t, err)
|
||||
require.True(t, entitlements.HasLicense)
|
||||
ctrl := gomock.NewController(t)
|
||||
mDB := dbmock.NewMockStore(ctrl)
|
||||
|
||||
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)
|
||||
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: tc.limit,
|
||||
},
|
||||
}).
|
||||
UserLimit(100)
|
||||
|
||||
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])
|
||||
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(tc.activeSeatCount, 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)
|
||||
|
||||
if tc.limit > 0 {
|
||||
require.NotNil(t, aiGovernanceSeatLimit.Actual)
|
||||
require.EqualValues(t, tc.activeSeatCount, *aiGovernanceSeatLimit.Actual)
|
||||
require.NotNil(t, aiGovernanceSeatLimit.Limit)
|
||||
require.EqualValues(t, tc.limit, *aiGovernanceSeatLimit.Limit)
|
||||
} else {
|
||||
require.Nil(t, aiGovernanceSeatLimit.Actual)
|
||||
require.Nil(t, aiGovernanceSeatLimit.Limit)
|
||||
}
|
||||
|
||||
if tc.expectedWarning == "" {
|
||||
require.Len(t, entitlements.Warnings, 0)
|
||||
} else {
|
||||
require.Len(t, entitlements.Warnings, 1)
|
||||
require.Equal(t, tc.expectedWarning, entitlements.Warnings[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("GracePeriodOverLimit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
limit int64 = 100
|
||||
activeSeatCount int64 = 127
|
||||
)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
mDB := dbmock.NewMockStore(ctrl)
|
||||
|
||||
licenseOpts := &coderdenttest.LicenseOptions{
|
||||
NotBefore: dbtime.Now().Add(-2 * time.Hour).Truncate(time.Second),
|
||||
GraceAt: dbtime.Now().Add(-time.Hour).Truncate(time.Second),
|
||||
ExpiresAt: dbtime.Now().Add(24 * time.Hour).Truncate(time.Second),
|
||||
Addons: []codersdk.Addon{codersdk.AddonAIGovernance},
|
||||
Features: license.Features{
|
||||
codersdk.FeatureAIGovernanceUserLimit: limit,
|
||||
},
|
||||
}
|
||||
|
||||
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(activeSeatCount, nil)
|
||||
mDB.EXPECT().
|
||||
GetTemplatesWithFilter(gomock.Any(), gomock.Any()).
|
||||
Return([]database.Template{}, nil)
|
||||
|
||||
enablements := map[codersdk.FeatureName]bool{
|
||||
codersdk.FeatureAIGovernanceUserLimit: true,
|
||||
}
|
||||
|
||||
entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, enablements)
|
||||
require.NoError(t, err)
|
||||
require.True(t, entitlements.HasLicense)
|
||||
|
||||
feature, ok := entitlements.Features[codersdk.FeatureAIGovernanceUserLimit]
|
||||
require.True(t, ok)
|
||||
require.Equal(t, codersdk.EntitlementGracePeriod, feature.Entitlement)
|
||||
|
||||
require.Contains(t, entitlements.Warnings,
|
||||
fmt.Sprintf(
|
||||
"Your deployment has %d active AI Governance seats but the license with the limit %d is expired.",
|
||||
activeSeatCount, limit,
|
||||
),
|
||||
)
|
||||
require.Contains(t, entitlements.Warnings,
|
||||
fmt.Sprintf(codersdk.LicenseAIGovernanceOverLimitWarningText, activeSeatCount, limit, 27),
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("GracePeriod90Percent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
limit int64 = 100
|
||||
activeSeatCount int64 = 95
|
||||
)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
mDB := dbmock.NewMockStore(ctrl)
|
||||
|
||||
licenseOpts := &coderdenttest.LicenseOptions{
|
||||
NotBefore: dbtime.Now().Add(-2 * time.Hour).Truncate(time.Second),
|
||||
GraceAt: dbtime.Now().Add(-time.Hour).Truncate(time.Second),
|
||||
ExpiresAt: dbtime.Now().Add(24 * time.Hour).Truncate(time.Second),
|
||||
Addons: []codersdk.Addon{codersdk.AddonAIGovernance},
|
||||
Features: license.Features{
|
||||
codersdk.FeatureAIGovernanceUserLimit: limit,
|
||||
},
|
||||
}
|
||||
|
||||
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(activeSeatCount, nil)
|
||||
mDB.EXPECT().
|
||||
GetTemplatesWithFilter(gomock.Any(), gomock.Any()).
|
||||
Return([]database.Template{}, nil)
|
||||
|
||||
enablements := map[codersdk.FeatureName]bool{
|
||||
codersdk.FeatureAIGovernanceUserLimit: true,
|
||||
}
|
||||
|
||||
entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, enablements)
|
||||
require.NoError(t, err)
|
||||
require.True(t, entitlements.HasLicense)
|
||||
|
||||
feature, ok := entitlements.Features[codersdk.FeatureAIGovernanceUserLimit]
|
||||
require.True(t, ok)
|
||||
require.Equal(t, codersdk.EntitlementGracePeriod, feature.Entitlement)
|
||||
|
||||
expiryWarning := fmt.Sprintf(
|
||||
"Your deployment has %d active AI Governance seats but the license with the limit %d is expired.",
|
||||
activeSeatCount,
|
||||
limit,
|
||||
)
|
||||
require.Contains(t, entitlements.Warnings, expiryWarning)
|
||||
require.Contains(t, entitlements.Warnings,
|
||||
fmt.Sprintf(codersdk.LicenseAIGovernance90PercentWarningText, 95))
|
||||
for _, warning := range entitlements.Warnings {
|
||||
require.NotContains(t, warning, "over the limit")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1344,7 +1537,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
|
||||
codersdk.FeatureAIBridge: false,
|
||||
}
|
||||
|
||||
aiBridgeWarningMessage := "The AI Governance Add-On is required to use AI Bridge. Please reach out to your account team or sales@coder.com to learn more."
|
||||
aiBridgeWarningMessage := "The AI Governance add-on is required to use AI Bridge. Please reach out to your account team or sales@coder.com to learn more."
|
||||
|
||||
t.Run("NoAddon_AIBridgeOff", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -1924,7 +2117,7 @@ func TestAIGovernanceAddon(t *testing.T) {
|
||||
// AI Bridge should be enabled without warning when addon is present.
|
||||
aibridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
|
||||
require.True(t, aibridgeFeature.Enabled, "AI Bridge should be enabled when addon is present and enablements are set")
|
||||
aiBridgeWarningMessage := "The AI Governance Add-On is required to use AI Bridge. Please reach out to your account team or sales@coder.com to learn more."
|
||||
aiBridgeWarningMessage := "The AI Governance add-on is required to use AI Bridge. Please reach out to your account team or sales@coder.com to learn more."
|
||||
require.NotContains(t, entitlements.Warnings, aiBridgeWarningMessage, "AI Bridge warning should not appear when AI Governance addon is present")
|
||||
|
||||
// require.Equal(t, codersdk.EntitlementEntitled, aibridgeFeature.Entitlement, "AI Bridge should be entitled when addon is present")
|
||||
|
||||
Generated
+8
@@ -3849,6 +3849,14 @@ export interface License {
|
||||
readonly claims: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// From codersdk/licenses.go
|
||||
export const LicenseAIGovernance90PercentWarningText =
|
||||
"You have used %d%% of your AI Governance add-on seats.";
|
||||
|
||||
// From codersdk/licenses.go
|
||||
export const LicenseAIGovernanceOverLimitWarningText =
|
||||
"Your organization is using %d of %d AI Governance add-on seats (%d over the limit).";
|
||||
|
||||
// From codersdk/licenses.go
|
||||
export const LicenseExpiryClaim = "license_expires";
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { ChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
@@ -20,15 +19,10 @@ export const Expander: FC<ExpanderProps> = ({
|
||||
return (
|
||||
<Collapsible open={expanded} onOpenChange={setExpanded}>
|
||||
<CollapsibleContent>
|
||||
<p className="flex items-center text-content-secondary text-xs">
|
||||
{children}
|
||||
</p>
|
||||
<div className="text-content-primary text-xs">{children}</div>
|
||||
</CollapsibleContent>
|
||||
<CollapsibleTrigger className="cursor-pointer text-content-secondary hover:underline">
|
||||
<span className="flex items-center text-xs">
|
||||
{expanded ? "Click here to hide" : "Click here to learn more"}
|
||||
<ChevronDownIcon open={expanded} className="size-4" />
|
||||
</span>
|
||||
<CollapsibleTrigger className="appearance-none bg-transparent border-0 cursor-pointer p-0 text-content-link text-xs hover:underline">
|
||||
{expanded ? "Show less" : "Show more"}
|
||||
</CollapsibleTrigger>
|
||||
</Collapsible>
|
||||
);
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import { chromatic } from "testHelpers/chromatic";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { LicenseAIGovernance90PercentWarningText } from "api/typesGenerated";
|
||||
import { expect, within } from "storybook/test";
|
||||
import { AIGovernanceSeatBannerView } from "./AIGovernanceSeatBannerView";
|
||||
|
||||
const meta: Meta<typeof AIGovernanceSeatBannerView> = {
|
||||
title: "modules/dashboard/AIGovernanceSeatBannerView",
|
||||
parameters: { chromatic },
|
||||
component: AIGovernanceSeatBannerView,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AIGovernanceSeatBannerView>;
|
||||
|
||||
export const OverLimit: Story = {
|
||||
args: {
|
||||
variant: "over-limit",
|
||||
actual: 110,
|
||||
limit: 100,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByRole("alert")).toHaveTextContent(
|
||||
/110 \/ 100 AI Governance user seats \(10% over the limit\)/,
|
||||
);
|
||||
await expect(
|
||||
canvas.getByRole("link", { name: "sales@coder.com" }),
|
||||
).toHaveAttribute("href", "mailto:sales@coder.com");
|
||||
},
|
||||
};
|
||||
|
||||
export const NearLimit: Story = {
|
||||
args: {
|
||||
variant: "near-limit",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByRole("alert")).toHaveTextContent(
|
||||
LicenseAIGovernance90PercentWarningText,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const FloorPercentage: Story = {
|
||||
args: {
|
||||
variant: "over-limit",
|
||||
actual: 106,
|
||||
limit: 101,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByRole("alert")).toHaveTextContent(
|
||||
/106 \/ 101 AI Governance user seats \(4% over the limit\)/,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const TinyOverage: Story = {
|
||||
args: {
|
||||
variant: "over-limit",
|
||||
actual: 1001,
|
||||
limit: 1000,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByRole("alert")).toHaveTextContent(
|
||||
/1001 \/ 1000 AI Governance user seats \(1% over the limit\)/,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeNumbers: Story = {
|
||||
args: {
|
||||
variant: "over-limit",
|
||||
actual: 1200,
|
||||
limit: 1000,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByRole("alert")).toHaveTextContent(
|
||||
/1200 \/ 1000 AI Governance user seats \(20% over the limit\)/,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { LicenseAIGovernance90PercentWarningText } from "api/typesGenerated";
|
||||
import { Link } from "components/Link/Link";
|
||||
import { TriangleAlertIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
|
||||
type AIGovernanceSeatBannerViewProps =
|
||||
| { variant: "over-limit"; actual: number; limit: number }
|
||||
| { variant: "near-limit" };
|
||||
|
||||
export const AIGovernanceSeatBannerView: FC<AIGovernanceSeatBannerViewProps> = (
|
||||
props,
|
||||
) => {
|
||||
if (props.variant === "near-limit") {
|
||||
return (
|
||||
<div role="alert" className="flex items-center bg-surface-secondary p-3">
|
||||
<div className="flex min-w-0 flex-1 items-start gap-2">
|
||||
<div className="flex h-[30px] items-center">
|
||||
<TriangleAlertIcon className="size-4 text-content-warning" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1 py-1.5 text-xs font-medium leading-4 text-content-primary">
|
||||
<span>{LicenseAIGovernance90PercentWarningText}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { actual, limit } = props;
|
||||
const overPercent = Math.max(1, Math.floor(((actual - limit) / limit) * 100));
|
||||
|
||||
return (
|
||||
<div role="alert" className="flex items-center bg-surface-orange p-3">
|
||||
<div className="flex min-w-0 flex-1 items-start gap-2">
|
||||
<div className="flex h-[30px] items-center">
|
||||
<TriangleAlertIcon className="size-4 text-content-warning" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1 py-1.5 text-xs font-medium leading-4 text-content-primary">
|
||||
<span>
|
||||
Your organization is using {actual} / {limit} AI Governance user
|
||||
seats ({overPercent}% over the limit). Contact{" "}
|
||||
</span>
|
||||
<Link href="mailto:sales@coder.com" showExternalIcon={false}>
|
||||
sales@coder.com
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,13 +1,60 @@
|
||||
import { server } from "testHelpers/server";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import {
|
||||
MockEntitlements,
|
||||
MockNoPermissions,
|
||||
MockPermissions,
|
||||
} from "#/testHelpers/entities";
|
||||
import {
|
||||
renderWithAuth,
|
||||
waitForLoaderToBeRemoved,
|
||||
} from "#/testHelpers/renderHelpers";
|
||||
import { server } from "#/testHelpers/server";
|
||||
import { DashboardLayout } from "./DashboardLayout";
|
||||
|
||||
const renderDashboardLayout = async ({
|
||||
actual,
|
||||
entitlement = "entitled",
|
||||
limit,
|
||||
permissions = MockPermissions,
|
||||
warnings,
|
||||
}: {
|
||||
actual?: number;
|
||||
entitlement?: "entitled" | "grace_period" | "not_entitled";
|
||||
limit?: number;
|
||||
permissions?: typeof MockPermissions;
|
||||
warnings?: string[];
|
||||
}) => {
|
||||
server.use(
|
||||
http.get("/api/v2/entitlements", () => {
|
||||
return HttpResponse.json({
|
||||
...MockEntitlements,
|
||||
warnings: warnings ?? MockEntitlements.warnings,
|
||||
has_license: true,
|
||||
refreshed_at: new Date().toISOString(),
|
||||
features: {
|
||||
...MockEntitlements.features,
|
||||
ai_governance_user_limit: {
|
||||
entitlement,
|
||||
enabled: true,
|
||||
...(actual !== undefined ? { actual } : {}),
|
||||
...(limit !== undefined ? { limit } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
http.post("/api/v2/authcheck", () => {
|
||||
return HttpResponse.json(permissions);
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithAuth(<DashboardLayout />, {
|
||||
children: [{ element: <h1>Test page</h1> }],
|
||||
});
|
||||
await waitForLoaderToBeRemoved();
|
||||
};
|
||||
|
||||
test("Show the new Coder version notification", async () => {
|
||||
server.use(
|
||||
http.get("/api/v2/updatecheck", () => {
|
||||
@@ -24,6 +71,32 @@ test("Show the new Coder version notification", async () => {
|
||||
await screen.findByTestId("update-check-snackbar");
|
||||
});
|
||||
|
||||
test("hides AI Governance seat warnings for non-admin users", async () => {
|
||||
await renderDashboardLayout({
|
||||
actual: 110,
|
||||
limit: 100,
|
||||
permissions: MockNoPermissions,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByText(/AI Governance add-on seats/),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows AI Governance over-limit warning in LicenseBanner for admin users", async () => {
|
||||
await renderDashboardLayout({
|
||||
actual: 110,
|
||||
limit: 100,
|
||||
permissions: MockPermissions,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
/110 of 100 AI Governance add-on seats \(10 over the limit\)/,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders a skip link before navigation content", async () => {
|
||||
renderWithAuth(<DashboardLayout />, {
|
||||
children: [{ element: <h1>Test page</h1> }],
|
||||
|
||||
@@ -1,14 +1,166 @@
|
||||
import type { FC } from "react";
|
||||
import {
|
||||
LicenseAIGovernance90PercentWarningText,
|
||||
LicenseAIGovernanceOverLimitWarningText,
|
||||
LicenseManagedAgentLimitExceededWarningText,
|
||||
LicenseTelemetryRequiredErrorText,
|
||||
} from "#/api/typesGenerated";
|
||||
import { useDashboard } from "#/modules/dashboard/useDashboard";
|
||||
import { LicenseBannerView } from "./LicenseBannerView";
|
||||
import { docs } from "#/utils/docs";
|
||||
import {
|
||||
type LicenseBannerLink,
|
||||
type LicenseBannerMessage,
|
||||
LicenseBannerView,
|
||||
} from "./LicenseBannerView";
|
||||
|
||||
export const LicenseBanner: FC = () => {
|
||||
const { entitlements } = useDashboard();
|
||||
const { errors, warnings } = entitlements;
|
||||
const aiGovernanceOverLimitWarningPrefix =
|
||||
LicenseAIGovernanceOverLimitWarningText.split("%d")[0];
|
||||
const aiGovernanceNearLimitWarningPrefix =
|
||||
LicenseAIGovernance90PercentWarningText.split("%d%%")[0];
|
||||
const AI_GOVERNANCE_NEAR_LIMIT_FALLBACK_MESSAGE =
|
||||
"You are approaching your AI Governance add-on seat limit.";
|
||||
|
||||
if (errors.length === 0 && warnings.length === 0) {
|
||||
const isAIGovernanceWarning = (message: string): boolean =>
|
||||
message.startsWith(aiGovernanceNearLimitWarningPrefix) ||
|
||||
message.startsWith(aiGovernanceOverLimitWarningPrefix);
|
||||
|
||||
const isAIGovernanceNearLimitWarning = (message: string): boolean =>
|
||||
message.startsWith(aiGovernanceNearLimitWarningPrefix);
|
||||
|
||||
const aiGovernanceOverLimitMessage = (
|
||||
feature: ReturnType<
|
||||
typeof useDashboard
|
||||
>["entitlements"]["features"]["ai_governance_user_limit"],
|
||||
): string | null => {
|
||||
if (!feature) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <LicenseBannerView errors={errors} warnings={warnings} />;
|
||||
const { actual, entitlement, limit } = feature;
|
||||
if (
|
||||
(entitlement !== "entitled" && entitlement !== "grace_period") ||
|
||||
actual === undefined ||
|
||||
limit === undefined ||
|
||||
limit <= 0 ||
|
||||
actual <= limit
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const overLimitSeats = actual - limit;
|
||||
return LicenseAIGovernanceOverLimitWarningText.replace("%d", `${actual}`)
|
||||
.replace("%d", `${limit}`)
|
||||
.replace("%d", `${overLimitSeats}`);
|
||||
};
|
||||
|
||||
const aiGovernanceNearLimitMessage = (
|
||||
feature: ReturnType<
|
||||
typeof useDashboard
|
||||
>["entitlements"]["features"]["ai_governance_user_limit"],
|
||||
): string | null => {
|
||||
if (!feature) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { actual, entitlement, limit } = feature;
|
||||
if (
|
||||
(entitlement !== "entitled" && entitlement !== "grace_period") ||
|
||||
actual === undefined ||
|
||||
limit === undefined ||
|
||||
limit <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const usedPercent = Math.floor((actual * 100) / limit);
|
||||
if (usedPercent < 90) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return LicenseAIGovernance90PercentWarningText.replace(
|
||||
"%d%%",
|
||||
`${usedPercent}%`,
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeAIGovernanceWarning = (
|
||||
message: string,
|
||||
feature: ReturnType<
|
||||
typeof useDashboard
|
||||
>["entitlements"]["features"]["ai_governance_user_limit"],
|
||||
): string => {
|
||||
if (message !== LicenseAIGovernance90PercentWarningText) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return (
|
||||
aiGovernanceNearLimitMessage(feature) ??
|
||||
AI_GOVERNANCE_NEAR_LIMIT_FALLBACK_MESSAGE
|
||||
);
|
||||
};
|
||||
|
||||
const messageLink = (message: string): LicenseBannerLink => {
|
||||
if (message === LicenseManagedAgentLimitExceededWarningText) {
|
||||
return {
|
||||
href: docs("/ai-coder/ai-governance"),
|
||||
label: "View AI Governance",
|
||||
showExternalIcon: true,
|
||||
target: "_blank",
|
||||
};
|
||||
}
|
||||
if (message === LicenseTelemetryRequiredErrorText) {
|
||||
return {
|
||||
href: "mailto:sales@coder.com",
|
||||
label: "Contact sales@coder.com if you need an exception.",
|
||||
showExternalIcon: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
href: "mailto:sales@coder.com",
|
||||
label: "Contact sales@coder.com.",
|
||||
showExternalIcon: false,
|
||||
};
|
||||
};
|
||||
|
||||
export const LicenseBanner: FC = () => {
|
||||
const { entitlements } = useDashboard();
|
||||
const { errors } = entitlements;
|
||||
const warnings = [...entitlements.warnings];
|
||||
const aiGovernanceUserLimitFeature =
|
||||
entitlements.features.ai_governance_user_limit;
|
||||
const overLimitWarning = aiGovernanceOverLimitMessage(
|
||||
aiGovernanceUserLimitFeature,
|
||||
);
|
||||
|
||||
if (
|
||||
overLimitWarning &&
|
||||
!warnings.some((warning) => isAIGovernanceWarning(warning))
|
||||
) {
|
||||
warnings.push(overLimitWarning);
|
||||
}
|
||||
|
||||
const normalizedWarnings = warnings.map((warning) =>
|
||||
normalizeAIGovernanceWarning(warning, aiGovernanceUserLimitFeature),
|
||||
);
|
||||
|
||||
const messages: LicenseBannerMessage[] = [
|
||||
...errors.map((message) => ({
|
||||
message,
|
||||
variant: "error" as const,
|
||||
link: messageLink(message),
|
||||
})),
|
||||
...normalizedWarnings.map((message) => ({
|
||||
message,
|
||||
variant: isAIGovernanceNearLimitWarning(message)
|
||||
? ("warning" as const)
|
||||
: ("warningProminent" as const),
|
||||
link: messageLink(message),
|
||||
})),
|
||||
];
|
||||
|
||||
if (messages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <LicenseBannerView messages={messages} />;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { chromatic } from "testHelpers/chromatic";
|
||||
import {
|
||||
MockAppearanceConfig,
|
||||
MockBuildInfo,
|
||||
MockDefaultOrganization,
|
||||
MockEntitlements,
|
||||
MockExperiments,
|
||||
} from "testHelpers/entities";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { LicenseManagedAgentLimitExceededWarningText } from "#/api/typesGenerated";
|
||||
import { chromatic } from "#/testHelpers/chromatic";
|
||||
import { expect, within } from "storybook/test";
|
||||
import {
|
||||
LicenseAIGovernance90PercentWarningText,
|
||||
LicenseManagedAgentLimitExceededWarningText,
|
||||
LicenseTelemetryRequiredErrorText,
|
||||
} from "#/api/typesGenerated";
|
||||
import { docs } from "#/utils/docs";
|
||||
import { DashboardContext, type DashboardValue } from "../DashboardProvider";
|
||||
import { LicenseBanner } from "./LicenseBanner";
|
||||
import { LicenseBannerView } from "./LicenseBannerView";
|
||||
|
||||
const meta: Meta<typeof LicenseBannerView> = {
|
||||
@@ -14,43 +29,243 @@ type Story = StoryObj<typeof LicenseBannerView>;
|
||||
|
||||
export const OneWarning: Story = {
|
||||
args: {
|
||||
errors: [],
|
||||
warnings: ["You have exceeded the number of seats in your license."],
|
||||
messages: [
|
||||
{
|
||||
message: "You have exceeded the number of seats in your license.",
|
||||
variant: "warningProminent",
|
||||
link: {
|
||||
href: "mailto:sales@coder.com",
|
||||
label: "Contact sales@coder.com.",
|
||||
showExternalIcon: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const TwoWarnings: Story = {
|
||||
args: {
|
||||
errors: [],
|
||||
warnings: [
|
||||
"You have exceeded the number of seats in your license.",
|
||||
"You are flying too close to the sun.",
|
||||
messages: [
|
||||
{
|
||||
message: "You have exceeded the number of seats in your license.",
|
||||
variant: "warningProminent",
|
||||
},
|
||||
{
|
||||
message: "You are flying too close to the sun.",
|
||||
variant: "warningProminent",
|
||||
},
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
canvas.queryByRole("button", { name: "Show more" }),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const ThreeWarnings: Story = {
|
||||
args: {
|
||||
messages: [
|
||||
{
|
||||
message: "You have exceeded the number of seats in your license.",
|
||||
variant: "warningProminent",
|
||||
},
|
||||
{
|
||||
message: "You are flying too close to the sun.",
|
||||
variant: "warningProminent",
|
||||
},
|
||||
{
|
||||
message: "Another warning that should be hidden until expanded.",
|
||||
variant: "warningProminent",
|
||||
},
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
canvas.getByRole("button", { name: "Show more" }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const OneError: Story = {
|
||||
args: {
|
||||
errors: [
|
||||
"You have multiple replicas but high availability is an Enterprise feature. You will be unable to connect to workspaces.",
|
||||
messages: [
|
||||
{
|
||||
message:
|
||||
"You have multiple replicas but high availability is an Enterprise feature. You will be unable to connect to workspaces.",
|
||||
variant: "error",
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const TwoErrors: Story = {
|
||||
args: {
|
||||
messages: [
|
||||
{
|
||||
message:
|
||||
"You have multiple replicas but high availability is an Enterprise feature.",
|
||||
variant: "error",
|
||||
},
|
||||
{
|
||||
message: "Telemetry is required for this deployment.",
|
||||
variant: "error",
|
||||
},
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
canvas.getByText("License errors require attention"),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const TelemetryRequiredError: Story = {
|
||||
args: {
|
||||
messages: [
|
||||
{
|
||||
message: LicenseTelemetryRequiredErrorText,
|
||||
variant: "error",
|
||||
link: {
|
||||
href: "mailto:sales@coder.com",
|
||||
label: "Contact sales@coder.com if you need an exception.",
|
||||
showExternalIcon: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByRole("alert")).toHaveTextContent(
|
||||
LicenseTelemetryRequiredErrorText,
|
||||
);
|
||||
await expect(
|
||||
canvas.getByRole("link", {
|
||||
name: /Contact sales@coder\.com if you need an exception\./i,
|
||||
}),
|
||||
).toHaveAttribute("href", "mailto:sales@coder.com");
|
||||
},
|
||||
};
|
||||
|
||||
export const ManagedAgentLimitExceeded: Story = {
|
||||
args: {
|
||||
errors: [],
|
||||
warnings: [LicenseManagedAgentLimitExceededWarningText],
|
||||
messages: [
|
||||
{
|
||||
message: LicenseManagedAgentLimitExceededWarningText,
|
||||
variant: "warningProminent",
|
||||
link: {
|
||||
href: docs("/ai-coder/ai-governance"),
|
||||
label: "View AI Governance",
|
||||
showExternalIcon: true,
|
||||
target: "_blank",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const ManagedAgentLimitExceededWithOtherWarnings: Story = {
|
||||
args: {
|
||||
errors: [],
|
||||
warnings: [
|
||||
LicenseManagedAgentLimitExceededWarningText,
|
||||
"You have exceeded the number of seats in your license.",
|
||||
messages: [
|
||||
{
|
||||
message: LicenseManagedAgentLimitExceededWarningText,
|
||||
variant: "warningProminent",
|
||||
},
|
||||
{
|
||||
message: "You have exceeded the number of seats in your license.",
|
||||
variant: "warningProminent",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const renderLicenseBannerWithAIGovernance = ({
|
||||
actual,
|
||||
entitlement = "entitled",
|
||||
limit,
|
||||
warnings = [],
|
||||
}: {
|
||||
actual: number;
|
||||
entitlement?: "entitled" | "grace_period" | "not_entitled";
|
||||
limit?: number;
|
||||
warnings?: string[];
|
||||
}) => {
|
||||
const mockDashboardValue: DashboardValue = {
|
||||
entitlements: {
|
||||
...MockEntitlements,
|
||||
has_license: true,
|
||||
warnings,
|
||||
features: {
|
||||
...MockEntitlements.features,
|
||||
ai_governance_user_limit: {
|
||||
enabled: true,
|
||||
entitlement,
|
||||
actual,
|
||||
...(limit !== undefined ? { limit } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
experiments: MockExperiments,
|
||||
appearance: MockAppearanceConfig,
|
||||
buildInfo: MockBuildInfo,
|
||||
organizations: [MockDefaultOrganization],
|
||||
showOrganizations: false,
|
||||
canViewOrganizationSettings: false,
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardContext.Provider value={mockDashboardValue}>
|
||||
<LicenseBanner />
|
||||
</DashboardContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const AIGovernanceNearLimit: Story = {
|
||||
render: () =>
|
||||
renderLicenseBannerWithAIGovernance({
|
||||
actual: 95,
|
||||
limit: 100,
|
||||
warnings: [LicenseAIGovernance90PercentWarningText],
|
||||
}),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByRole("status")).toHaveTextContent(
|
||||
"You have used 95% of your AI Governance add-on seats.",
|
||||
);
|
||||
await expect(
|
||||
canvas.getByRole("link", { name: /Contact sales@coder\.com/i }),
|
||||
).toHaveAttribute("href", "mailto:sales@coder.com");
|
||||
},
|
||||
};
|
||||
|
||||
export const AIGovernanceOverLimitFromFeature: Story = {
|
||||
render: () =>
|
||||
renderLicenseBannerWithAIGovernance({
|
||||
actual: 110,
|
||||
limit: 100,
|
||||
}),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByRole("status")).toHaveTextContent(
|
||||
/110 of 100 AI Governance add-on seats \(10 over the limit\)/,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const AIGovernanceOverLimitGracePeriod: Story = {
|
||||
render: () =>
|
||||
renderLicenseBannerWithAIGovernance({
|
||||
actual: 110,
|
||||
entitlement: "grace_period",
|
||||
limit: 100,
|
||||
}),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByRole("status")).toHaveTextContent(
|
||||
/110 of 100 AI Governance add-on seats \(10 over the limit\)/,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { cva } from "class-variance-authority";
|
||||
import { TriangleAlertIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
LicenseManagedAgentLimitExceededWarningText,
|
||||
LicenseTelemetryRequiredErrorText,
|
||||
} from "#/api/typesGenerated";
|
||||
import { Expander } from "#/components/Expander/Expander";
|
||||
import { Link } from "#/components/Link/Link";
|
||||
import { Pill } from "#/components/Pill/Pill";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { docs } from "#/utils/docs";
|
||||
|
||||
const formatMessage = (message: string) => {
|
||||
// If the message ends with an alphanumeric character, add a period.
|
||||
@@ -17,96 +13,160 @@ const formatMessage = (message: string) => {
|
||||
return message;
|
||||
};
|
||||
|
||||
const messageLinkProps = (
|
||||
message: string,
|
||||
): Pick<React.ComponentProps<typeof Link>, "href" | "children" | "target"> => {
|
||||
if (message === LicenseManagedAgentLimitExceededWarningText) {
|
||||
return {
|
||||
href: docs("/ai-coder/ai-governance"),
|
||||
children: "View AI Governance",
|
||||
target: "_blank",
|
||||
};
|
||||
}
|
||||
if (message === LicenseTelemetryRequiredErrorText) {
|
||||
return {
|
||||
href: "mailto:sales@coder.com",
|
||||
children: "Contact sales@coder.com if you need an exception.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
href: "mailto:sales@coder.com",
|
||||
children: "Contact sales@coder.com.",
|
||||
};
|
||||
};
|
||||
type LicenseBannerVariant = "warning" | "warningProminent" | "error";
|
||||
|
||||
interface LicenseBannerViewProps {
|
||||
errors: readonly string[];
|
||||
warnings: readonly string[];
|
||||
export interface LicenseBannerLink {
|
||||
href: string;
|
||||
label: string;
|
||||
showExternalIcon?: boolean;
|
||||
target?: React.ComponentProps<typeof Link>["target"];
|
||||
}
|
||||
|
||||
export const LicenseBannerView: React.FC<LicenseBannerViewProps> = ({
|
||||
errors,
|
||||
warnings,
|
||||
}) => {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const isError = errors.length > 0;
|
||||
const messages = [...errors, ...warnings];
|
||||
const type = isError ? "error" : "warning";
|
||||
export interface LicenseBannerMessage {
|
||||
message: string;
|
||||
variant: LicenseBannerVariant;
|
||||
link?: LicenseBannerLink;
|
||||
}
|
||||
|
||||
if (messages.length === 1) {
|
||||
const [message] = messages;
|
||||
const bannerVariants = cva("flex items-center p-3", {
|
||||
variants: {
|
||||
variant: {
|
||||
warning: "bg-surface-secondary",
|
||||
warningProminent: "bg-surface-orange",
|
||||
error: "bg-surface-red",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center p-3 text-sm",
|
||||
isError ? "bg-surface-red" : "bg-surface-orange",
|
||||
)}
|
||||
>
|
||||
<Pill type={type}>License Issue</Pill>
|
||||
<div className="mx-2">
|
||||
<span>{formatMessage(message)}</span>
|
||||
|
||||
<Link
|
||||
className={cn(
|
||||
"font-medium",
|
||||
isError ? "!text-content-destructive" : "!text-content-warning",
|
||||
)}
|
||||
{...messageLinkProps(message)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const iconVariants = cva("size-4", {
|
||||
variants: {
|
||||
variant: {
|
||||
warning: "text-content-warning",
|
||||
warningProminent: "text-content-warning",
|
||||
error: "text-content-destructive",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
interface LicenseBannerViewProps {
|
||||
messages: readonly LicenseBannerMessage[];
|
||||
}
|
||||
|
||||
const messageLinkClass = "text-xs font-medium !text-content-link";
|
||||
const listClass =
|
||||
"m-0 list-disc space-y-1 pl-4 text-xs leading-[18px] text-content-primary";
|
||||
|
||||
const getBannerVariant = (
|
||||
messages: readonly LicenseBannerMessage[],
|
||||
): LicenseBannerVariant => {
|
||||
const hasError = messages.some((entry) => entry.variant === "error");
|
||||
if (hasError) {
|
||||
return "error";
|
||||
}
|
||||
|
||||
const hasProminentWarning = messages.some(
|
||||
(entry) => entry.variant === "warningProminent",
|
||||
);
|
||||
return hasProminentWarning ? "warningProminent" : "warning";
|
||||
};
|
||||
|
||||
const bannerTitle = (variant: LicenseBannerVariant): string =>
|
||||
variant === "error"
|
||||
? "License errors require attention"
|
||||
: "Your license limits have been exceeded";
|
||||
|
||||
const bannerRole = (variant: LicenseBannerVariant): "alert" | "status" =>
|
||||
variant === "error" ? "alert" : "status";
|
||||
|
||||
const LicenseMessageText: React.FC<{
|
||||
entry: LicenseBannerMessage;
|
||||
}> = ({ entry }) => (
|
||||
<>
|
||||
{formatMessage(entry.message)}{" "}
|
||||
{entry.link && (
|
||||
<Link
|
||||
className={messageLinkClass}
|
||||
href={entry.link.href}
|
||||
showExternalIcon={entry.link.showExternalIcon}
|
||||
target={entry.link.target}
|
||||
>
|
||||
{entry.link.label}
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const LicenseMessageList: React.FC<{
|
||||
messages: readonly LicenseBannerMessage[];
|
||||
}> = ({ messages }) => (
|
||||
<ul className={listClass}>
|
||||
{messages.map((entry, index) => (
|
||||
<li key={`${entry.message}-${index}`}>
|
||||
<LicenseMessageText entry={entry} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
const ExpandableLicenseMessageList: React.FC<{
|
||||
visibleMessages: readonly LicenseBannerMessage[];
|
||||
hiddenMessages: readonly LicenseBannerMessage[];
|
||||
}> = ({ visibleMessages, hiddenMessages }) => {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const showExpander = hiddenMessages.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<LicenseMessageList messages={visibleMessages} />
|
||||
{showExpander && (
|
||||
<Expander expanded={showDetails} setExpanded={setShowDetails}>
|
||||
<LicenseMessageList messages={hiddenMessages} />
|
||||
</Expander>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const LicenseBannerView: React.FC<LicenseBannerViewProps> = ({
|
||||
messages,
|
||||
}) => {
|
||||
if (messages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isSingleMessage = messages.length === 1;
|
||||
const bannerVariant = getBannerVariant(messages);
|
||||
const visibleMessages = messages.slice(0, 2);
|
||||
const hiddenMessages = messages.slice(2);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center p-3 text-sm",
|
||||
isError ? "bg-surface-red" : "bg-surface-orange",
|
||||
)}
|
||||
role={bannerRole(bannerVariant)}
|
||||
className={cn(bannerVariants({ variant: bannerVariant }))}
|
||||
>
|
||||
<Pill type={type}>{`${messages.length} License Issues`}</Pill>
|
||||
<div className="mx-2">
|
||||
<div>It looks like you've exceeded some limits of your license.</div>
|
||||
<Expander expanded={showDetails} setExpanded={setShowDetails}>
|
||||
<ul className="p-2 m-0">
|
||||
{messages.map((message) => (
|
||||
<li className="m-1" key={message}>
|
||||
{formatMessage(message)}
|
||||
<Link
|
||||
className={cn(
|
||||
"font-medium text-xs px-0",
|
||||
isError
|
||||
? "!text-content-destructive"
|
||||
: "!text-content-warning",
|
||||
)}
|
||||
{...messageLinkProps(message)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Expander>
|
||||
<div className="flex min-w-0 flex-1 items-start gap-2">
|
||||
<div className="flex h-6 items-center">
|
||||
<TriangleAlertIcon
|
||||
className={cn(iconVariants({ variant: bannerVariant }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
{isSingleMessage ? (
|
||||
<div className="flex min-h-6 items-center text-xs leading-4 text-content-primary">
|
||||
<LicenseMessageText entry={messages[0]} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm font-semibold leading-6 text-content-primary">
|
||||
{bannerTitle(bannerVariant)}
|
||||
</div>
|
||||
<ExpandableLicenseMessageList
|
||||
hiddenMessages={hiddenMessages}
|
||||
visibleMessages={visibleMessages}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user