Show properties and orgs usage in Usage tab

This commit is contained in:
Taras Kushnir
2025-11-23 08:46:02 +02:00
parent f4643f830a
commit fbc9623772
7 changed files with 70 additions and 2 deletions

View File

@@ -52,6 +52,8 @@ func (p *basePlan) PriceIDs() (string, string) { return p.priceIDMonthl
func (p *basePlan) TrialDays() int { return 14 }
func (p *basePlan) RequestsLimit() int64 { return p.requestsLimit }
func (p *basePlan) APIRequestsPerSecond() float64 { return p.apiRequestsPerSecond }
func (p *basePlan) PropertiesLimit() int { return 50 }
func (p *basePlan) OrgsLimit() int { return 10 }
const (
version1 = 1
@@ -74,6 +76,8 @@ type Plan interface {
CheckPropertiesLimit(count int) bool
TrialDays() int
RequestsLimit() int64
PropertiesLimit() int
OrgsLimit() int
APIRequestsPerSecond() float64
}

View File

@@ -870,6 +870,7 @@ func (impl *BusinessStoreImpl) CreateNewProperty(ctx context.Context, params *db
impl.cacheProperty(ctx, property)
// invalidate org properties in cache as we just created a new property
_ = impl.cache.Delete(ctx, orgPropertiesCacheKey(params.OrgID.Int32))
_ = impl.cache.Delete(ctx, userPropertiesCountCacheKey(property.CreatorID.Int32))
auditEvent := newCreatePropertyAuditLogEvent(property, org)
@@ -919,6 +920,7 @@ func (impl *BusinessStoreImpl) SoftDeleteProperty(ctx context.Context, prop *dbg
_ = impl.cache.SetMissing(ctx, propertyByIDCacheKey(prop.ID))
// invalidate org properties in cache as we just deleted a property
_ = impl.cache.Delete(ctx, orgPropertiesCacheKey(org.ID))
_ = impl.cache.Delete(ctx, userPropertiesCountCacheKey(user.ID))
auditEvent := newDeletePropertyAuditLogEvent(prop, org, user)
@@ -987,6 +989,7 @@ func (impl *BusinessStoreImpl) SoftDeleteOrganization(ctx context.Context, org *
_ = impl.cache.SetMissing(ctx, orgCacheKey(org.ID))
// invalidate user orgs in cache as we just deleted one
_ = impl.cache.Delete(ctx, userOrgsCacheKey(user.ID))
_ = impl.cache.Delete(ctx, userPropertiesCountCacheKey(user.ID))
auditEvent := newOrgAuditLogEvent(user.ID, org, common.AuditLogActionSoftDelete)
@@ -1688,6 +1691,11 @@ func (impl *BusinessStoreImpl) RetrieveUserPropertiesCount(ctx context.Context,
return 0, ErrMaintenance
}
cacheKey := userPropertiesCountCacheKey(userID)
if count, err := FetchCachedOne[int64](ctx, impl.cache, cacheKey); err == nil {
return *count, nil
}
count, err := impl.querier.GetUserPropertiesCount(ctx, Int(userID))
if err != nil {
slog.ErrorContext(ctx, "Failed to retrieve user properties count", "userID", userID, common.ErrAttr(err))
@@ -1696,6 +1704,11 @@ func (impl *BusinessStoreImpl) RetrieveUserPropertiesCount(ctx context.Context,
slog.DebugContext(ctx, "Fetched user properties count", "userID", userID, "count", count)
const propertiesCountTTL = 5 * time.Minute
c := new(int64)
*c = count
_ = impl.cache.SetWithTTL(ctx, cacheKey, c, propertiesCountTTL)
return count, nil
}

View File

@@ -205,6 +205,7 @@ const (
userAuditLogsCacheKeyPrefix
propertyAuditLogsCacheKeyPrefix
orgAuditLogsCacheKeyPrefix
userPropertiesCountCachePrefix
// Add new fields _above_
CACHE_KEY_PREFIXES_COUNT
)
@@ -238,6 +239,7 @@ func init() {
cachePrefixToStrings[userAuditLogsCacheKeyPrefix] = "userAuditLogs/"
cachePrefixToStrings[propertyAuditLogsCacheKeyPrefix] = "propAuditLogs/"
cachePrefixToStrings[orgAuditLogsCacheKeyPrefix] = "orgAuditLogs/"
cachePrefixToStrings[userPropertiesCountCachePrefix] = "userPropertiesCount/"
for i, v := range cachePrefixToStrings {
if len(v) == 0 {
@@ -338,3 +340,6 @@ func propertyAuditLogsCacheKey(propID int32) CacheKey {
func orgAuditLogsCacheKey(orgID int32) CacheKey {
return CacheKey{Prefix: orgAuditLogsCacheKeyPrefix, IntValue: orgID}
}
func userPropertiesCountCacheKey(userID int32) CacheKey {
return Int32CacheKey(userPropertiesCountCachePrefix, userID)
}

View File

@@ -319,7 +319,11 @@ func TestRenderHTML(t *testing.T) {
ActiveTabID: common.UsageEndpoint,
Tabs: CreateTabViewModels(common.UsageEndpoint, server.SettingsTabs),
},
Limit: 12345,
OrgsCount: 2,
PropertiesCount: 10,
IncludedOrgsCount: 10,
IncludedPropertiesCount: 50,
Limit: 12345,
},
selector: "",
matches: []string{},

View File

@@ -66,7 +66,11 @@ type SettingsCommonRenderContext struct {
type settingsUsageRenderContext struct {
SettingsCommonRenderContext
Limit int
PropertiesCount int
OrgsCount int
IncludedPropertiesCount int
IncludedOrgsCount int
Limit int
}
type settingsGeneralRenderContext struct {
@@ -720,6 +724,14 @@ func (s *Server) createUsageSettingsModel(ctx context.Context, user *dbgen.User)
SettingsCommonRenderContext: s.CreateSettingsCommonRenderContext(common.UsageEndpoint, user),
}
if orgs, err := s.Store.Impl().RetrieveUserOrganizations(ctx, user); err == nil {
renderCtx.OrgsCount = len(orgs)
}
if count, err := s.Store.Impl().RetrieveUserPropertiesCount(ctx, user.ID); err == nil {
renderCtx.PropertiesCount = int(count)
}
if user.SubscriptionID.Valid {
subscription, err := s.Store.Impl().RetrieveSubscription(ctx, user.SubscriptionID.Int32)
if err != nil {
@@ -729,6 +741,8 @@ func (s *Server) createUsageSettingsModel(ctx context.Context, user *dbgen.User)
if plan, err := s.PlanService.FindPlan(subscription.ExternalProductID, subscription.ExternalPriceID, s.Stage,
db.IsInternalSubscription(subscription.Source)); err == nil {
renderCtx.Limit = int(plan.RequestsLimit())
renderCtx.IncludedPropertiesCount = plan.PropertiesLimit()
renderCtx.IncludedOrgsCount = plan.OrgsLimit()
} else {
slog.ErrorContext(ctx, "Failed to find billing plan for usage tab", "productID", subscription.ExternalProductID, "priceID", subscription.ExternalPriceID, common.ErrAttr(err))
renderCtx.ErrorMessage = "Could not determine usage limits from your plan."

View File

@@ -13,6 +13,23 @@
<p class="text-base font-bold text-gray-900 lg:order-1">Monthly usage</p>
</div>
<div class="mt-5">
<dl class="grid grid-cols-1 gap-5 sm:grid-cols-3">
<div class="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6">
<dt class="truncate text-sm font-medium text-gray-500">Organizations</dt>
<dd class="mt-1 text-3xl font-semibold tracking-tight text-gray-500">{{if .Params.IncludedOrgsCount}}<span class="text-gray-900">{{.Params.OrgsCount}}</span> / {{.Params.IncludedOrgsCount}}{{else}}<span class="text-xl">Calculating...</span>{{end}}</dd>
</div>
<div class="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6">
<dt class="truncate text-sm font-medium text-gray-500">Properties</dt>
<dd class="mt-1 text-3xl font-semibold tracking-tight text-gray-500">{{if .Params.IncludedPropertiesCount}}<span class="text-gray-900">{{.Params.PropertiesCount}}</span> / {{.Params.IncludedPropertiesCount}}{{else}}<span class="text-xl">Calculating...</span>{{end}}</dd>
</div>
<div class="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6">
<dt class="truncate text-sm font-medium text-gray-500">Captcha requests</dt>
<dd class="mt-1 text-3xl font-semibold tracking-tight text-gray-900" id="totalRequests">Calculating...</dd>
</div>
</dl>
</div>
<div class="mt-6 min-h-96" id="usage-chart"></div>
<div id="usage-spinner" class="absolute inset-0 flex justify-center items-center z-10 hidden">

View File

@@ -239,8 +239,19 @@
const response = await this.fetchChartData(spinnerElement);
if (response && response.data && response.data.length > 0) {
this.setChartData(chartElement, response, this.monthlyTicks.bind(this));
const totalElement = document.getElementById("totalRequests");
if (totalElement) {
const sum = response.data.reduce((sum, item) => sum + item.y, 0);
const formatter = new Intl.NumberFormat('en', {
notation: 'compact',
compactDisplay: 'short',
});
totalElement.innerHTML = formatter.format(sum);;
}
} else {
this.drawNoData(chartElement, this.monthlyTicks.bind(this), 365);
totalElement.innerHTML = "N/A";
}
}