mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08: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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user