mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
Usage page in settings (#1595)
## Summary Adds a Usage page under Project Settings showing the owner team's plan, billing period, and resource consumption (dashboard admins, auth users, emails, analytics events, session replays) with progress bars and an upgrade CTA. Backend aggregates usage across the team via `sumTenancyUsage` (parallelized with `Promise.all`) and serves it through `GET /internal/plan-usage`. Shared types in `@hexclave/shared` define the contract consumed by the SDK and dashboard. ## Screenshots  Link to Devin session: https://app.devin.ai/sessions/1bc3344126a442adb4f29ae373d346be Requested by: @Developing-Gamer <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added Usage page in project settings displaying comprehensive plan usage metrics including dashboard seats, authentication users, emails sent, analytics events, and session replays * Shows current usage against plan limits with visual progress indicators * Displays upgrade recommendations when plan limits are exceeded with one-click upgrade functionality * Added Usage menu item to project settings navigation <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: armaan <armaan@stack-auth.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
bd511a6255
commit
80ba110e78
@ -0,0 +1,28 @@
|
||||
import { getPlanUsageForProject } from "@/lib/plan-usage";
|
||||
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
|
||||
import { planUsageResponseSchema } from "@hexclave/shared/dist/interface/plan-usage";
|
||||
import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
|
||||
|
||||
export const GET = createSmartRouteHandler({
|
||||
metadata: {
|
||||
hidden: true,
|
||||
},
|
||||
request: yupObject({
|
||||
auth: yupObject({
|
||||
type: adminAuthTypeSchema.defined(),
|
||||
tenancy: adaptSchema.defined(),
|
||||
}),
|
||||
}),
|
||||
response: yupObject({
|
||||
statusCode: yupNumber().oneOf([200]).defined(),
|
||||
bodyType: yupString().oneOf(["json"]).defined(),
|
||||
body: planUsageResponseSchema,
|
||||
}),
|
||||
handler: async (req) => {
|
||||
return {
|
||||
statusCode: 200,
|
||||
bodyType: "json",
|
||||
body: await getPlanUsageForProject(req.auth.tenancy.project),
|
||||
};
|
||||
},
|
||||
});
|
||||
165
apps/backend/src/lib/plan-usage.test.ts
Normal file
165
apps/backend/src/lib/plan-usage.test.ts
Normal file
@ -0,0 +1,165 @@
|
||||
import { ITEM_IDS, UNLIMITED } from "@hexclave/shared/dist/plans";
|
||||
import type { SubscriptionRow } from "./payments/schema/types";
|
||||
import { buildUsageRow, getNextPlanId, getPlanUsagePeriod } from "./plan-usage";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function createSubscriptionPeriod(startMillis: number, endMillis: number): SubscriptionRow {
|
||||
return {
|
||||
id: "sub_1",
|
||||
tenancyId: "tenancy_1",
|
||||
customerId: "team_1",
|
||||
customerType: "team",
|
||||
productId: "team",
|
||||
priceId: "monthly",
|
||||
product: {
|
||||
displayName: "Team",
|
||||
customerType: "team",
|
||||
prices: {},
|
||||
includedItems: {},
|
||||
},
|
||||
quantity: 1,
|
||||
stripeSubscriptionId: null,
|
||||
status: "active",
|
||||
currentPeriodStartMillis: startMillis,
|
||||
currentPeriodEndMillis: endMillis,
|
||||
cancelAtPeriodEnd: false,
|
||||
canceledAtMillis: null,
|
||||
endedAtMillis: null,
|
||||
refundedAtMillis: null,
|
||||
productRevokedAtMillis: null,
|
||||
creationSource: "TEST_MODE",
|
||||
createdAtMillis: startMillis,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildUsageRow", () => {
|
||||
it("calculates remaining usage under the limit", () => {
|
||||
expect(buildUsageRow({
|
||||
itemId: ITEM_IDS.emailsPerMonth,
|
||||
displayName: "Emails per month",
|
||||
kind: "metered",
|
||||
used: 25,
|
||||
limit: 100,
|
||||
})).toMatchInlineSnapshot(`
|
||||
{
|
||||
"display_name": "Emails per month",
|
||||
"is_unlimited": false,
|
||||
"item_id": "emails_per_month",
|
||||
"kind": "metered",
|
||||
"limit": 100,
|
||||
"overage": 0,
|
||||
"remaining": 75,
|
||||
"used": 25,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("treats exact limit as no overage", () => {
|
||||
expect(buildUsageRow({
|
||||
itemId: ITEM_IDS.analyticsEvents,
|
||||
displayName: "Analytics events",
|
||||
kind: "metered",
|
||||
used: 100,
|
||||
limit: 100,
|
||||
})).toMatchInlineSnapshot(`
|
||||
{
|
||||
"display_name": "Analytics events",
|
||||
"is_unlimited": false,
|
||||
"item_id": "analytics_events",
|
||||
"kind": "metered",
|
||||
"limit": 100,
|
||||
"overage": 0,
|
||||
"remaining": 0,
|
||||
"used": 100,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("calculates overage when usage exceeds the limit", () => {
|
||||
expect(buildUsageRow({
|
||||
itemId: ITEM_IDS.sessionReplays,
|
||||
displayName: "Session replays",
|
||||
kind: "metered",
|
||||
used: 125,
|
||||
limit: 100,
|
||||
})).toMatchInlineSnapshot(`
|
||||
{
|
||||
"display_name": "Session replays",
|
||||
"is_unlimited": false,
|
||||
"item_id": "session_replays",
|
||||
"kind": "metered",
|
||||
"limit": 100,
|
||||
"overage": 25,
|
||||
"remaining": 0,
|
||||
"used": 125,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("represents unlimited auth users without remaining or overage", () => {
|
||||
expect(buildUsageRow({
|
||||
itemId: ITEM_IDS.authUsers,
|
||||
displayName: "Auth users",
|
||||
kind: "current",
|
||||
used: 250_000,
|
||||
limit: UNLIMITED,
|
||||
})).toMatchInlineSnapshot(`
|
||||
{
|
||||
"display_name": "Auth users",
|
||||
"is_unlimited": true,
|
||||
"item_id": "auth_users",
|
||||
"kind": "current",
|
||||
"limit": null,
|
||||
"overage": 0,
|
||||
"remaining": null,
|
||||
"used": 250000,
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan upgrade targets", () => {
|
||||
it("selects the next paid tier", () => {
|
||||
expect({
|
||||
free: getNextPlanId("free"),
|
||||
team: getNextPlanId("team"),
|
||||
growth: getNextPlanId("growth"),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"free": "team",
|
||||
"growth": null,
|
||||
"team": "growth",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("billing period selection", () => {
|
||||
it("uses the subscription period when available", () => {
|
||||
const start = Date.UTC(2026, 4, 15);
|
||||
const end = Date.UTC(2026, 5, 15);
|
||||
const period = getPlanUsagePeriod(createSubscriptionPeriod(start, end), new Date(Date.UTC(2026, 5, 11)));
|
||||
expect({
|
||||
start: period.start.toISOString(),
|
||||
end: period.end.toISOString(),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"end": "2026-06-15T00:00:00.000Z",
|
||||
"start": "2026-05-15T00:00:00.000Z",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("falls back to the current calendar month", () => {
|
||||
const period = getPlanUsagePeriod(null, new Date(Date.UTC(2026, 5, 11, 12)));
|
||||
expect({
|
||||
start: period.start.toISOString(),
|
||||
end: period.end.toISOString(),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"end": "2026-07-01T00:00:00.000Z",
|
||||
"start": "2026-06-01T00:00:00.000Z",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
370
apps/backend/src/lib/plan-usage.ts
Normal file
370
apps/backend/src/lib/plan-usage.ts
Normal file
@ -0,0 +1,370 @@
|
||||
import { VerificationCodeType } from "@/generated/prisma/client";
|
||||
import { getClickhouseAdminClientForMetrics } from "@/lib/clickhouse";
|
||||
import { getSubscriptionMapForCustomer } from "@/lib/payments/customer-data";
|
||||
import { isActiveSubscription } from "@/lib/payments";
|
||||
import {
|
||||
getBillingTeamId,
|
||||
getOwnedProjectIdsForBillingTeam,
|
||||
getOwnedTenancyIdsForBillingTeam,
|
||||
getTeamWideNonAnonymousUserCount,
|
||||
} from "@/lib/plan-entitlements";
|
||||
import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch, getTenancy, type Tenancy } from "@/lib/tenancies";
|
||||
import { getPrismaClientForTenancy, getPrismaSchemaForTenancy, globalPrismaClient, sqlQuoteIdent } from "@/prisma-client";
|
||||
import { BASE_PLAN_IDS_BY_TIER, ITEM_IDS, PLAN_LIMITS, UNLIMITED, type ItemId, type PlanId } from "@hexclave/shared/dist/plans";
|
||||
import type { PlanUsageResponse } from "@hexclave/shared/dist/interface/admin-interface";
|
||||
import { HexclaveAssertionError, throwErr } from "@hexclave/shared/dist/utils/errors";
|
||||
import type { SubscriptionRow } from "./payments/schema/types";
|
||||
|
||||
type PlanUsageKind = PlanUsageResponse["rows"][number]["kind"];
|
||||
type PlanUsageRow = PlanUsageResponse["rows"][number];
|
||||
type UsageLimit = number | null;
|
||||
|
||||
type UsagePeriod = {
|
||||
start: Date,
|
||||
end: Date,
|
||||
};
|
||||
|
||||
type UsageSourceProject = {
|
||||
id: string,
|
||||
ownerTeamId?: string | null,
|
||||
owner_team_id?: string | null,
|
||||
};
|
||||
|
||||
const USAGE_ITEM_LABELS = new Map<ItemId, string>([
|
||||
[ITEM_IDS.seats, "Dashboard admins"],
|
||||
[ITEM_IDS.authUsers, "Auth users"],
|
||||
[ITEM_IDS.emailsPerMonth, "Emails per month"],
|
||||
[ITEM_IDS.analyticsEvents, "Analytics events"],
|
||||
[ITEM_IDS.sessionReplays, "Session replays"],
|
||||
[ITEM_IDS.analyticsTimeoutSeconds, "Analytics timeout"],
|
||||
[ITEM_IDS.onboardingCall, "Onboarding call"],
|
||||
]);
|
||||
|
||||
const PLAN_LABELS = new Map<PlanId, string>([
|
||||
["free", "Free"],
|
||||
["team", "Team"],
|
||||
["growth", "Growth"],
|
||||
]);
|
||||
|
||||
export function getNextPlanId(planId: PlanId): "team" | "growth" | null {
|
||||
if (planId === "free") {
|
||||
return "team";
|
||||
}
|
||||
if (planId === "team") {
|
||||
return "growth";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildUsageRow(options: {
|
||||
itemId: ItemId,
|
||||
displayName: string,
|
||||
kind: PlanUsageKind,
|
||||
used: number | null,
|
||||
limit: UsageLimit,
|
||||
}): PlanUsageRow {
|
||||
if (options.kind === "capability") {
|
||||
return {
|
||||
item_id: options.itemId,
|
||||
display_name: options.displayName,
|
||||
kind: options.kind,
|
||||
used: null,
|
||||
limit: options.limit,
|
||||
remaining: null,
|
||||
overage: null,
|
||||
is_unlimited: options.limit != null && options.limit >= UNLIMITED,
|
||||
};
|
||||
}
|
||||
|
||||
const used = options.used ?? throwErr(`Used value is required for ${options.itemId}`);
|
||||
const isUnlimited = options.limit != null && options.limit >= UNLIMITED;
|
||||
const remaining = isUnlimited || options.limit == null ? null : Math.max(0, options.limit - used);
|
||||
const overage = isUnlimited || options.limit == null ? 0 : Math.max(0, used - options.limit);
|
||||
|
||||
return {
|
||||
item_id: options.itemId,
|
||||
display_name: options.displayName,
|
||||
kind: options.kind,
|
||||
used,
|
||||
limit: isUnlimited ? null : options.limit,
|
||||
remaining,
|
||||
overage,
|
||||
is_unlimited: isUnlimited,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCurrentCalendarMonthPeriod(now: Date): UsagePeriod {
|
||||
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||
const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1));
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
export function getPlanUsagePeriod(activeSubscription: SubscriptionRow | null, now: Date): UsagePeriod {
|
||||
if (activeSubscription?.currentPeriodEndMillis != null) {
|
||||
const end = new Date(activeSubscription.currentPeriodEndMillis);
|
||||
if (Number.isFinite(activeSubscription.currentPeriodStartMillis)) {
|
||||
return {
|
||||
start: new Date(activeSubscription.currentPeriodStartMillis),
|
||||
end,
|
||||
};
|
||||
}
|
||||
|
||||
const start = new Date(end);
|
||||
start.setUTCMonth(start.getUTCMonth() - 1);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
return getCurrentCalendarMonthPeriod(now);
|
||||
}
|
||||
|
||||
function formatClickhouseDateTimeParam(date: Date): string {
|
||||
return date.toISOString().slice(0, 19);
|
||||
}
|
||||
|
||||
function getPlanLabel(planId: PlanId): string {
|
||||
return PLAN_LABELS.get(planId) ?? throwErr(`Missing plan label for ${planId}`);
|
||||
}
|
||||
|
||||
function getUsageItemLabel(itemId: ItemId): string {
|
||||
return USAGE_ITEM_LABELS.get(itemId) ?? throwErr(`Missing usage item label for ${itemId}`);
|
||||
}
|
||||
|
||||
function resolveActivePlanSubscription(subscriptions: Record<string, SubscriptionRow>): SubscriptionRow | null {
|
||||
const activeSubscriptions = Object.values(subscriptions).filter(isActiveSubscription);
|
||||
for (const planId of BASE_PLAN_IDS_BY_TIER) {
|
||||
const subscription = activeSubscriptions.find((candidate) => candidate.productId === planId);
|
||||
if (subscription != null) {
|
||||
return subscription;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveActivePlanId(subscription: SubscriptionRow | null): PlanId {
|
||||
for (const planId of BASE_PLAN_IDS_BY_TIER) {
|
||||
if (subscription?.productId === planId) {
|
||||
return planId;
|
||||
}
|
||||
}
|
||||
return "free";
|
||||
}
|
||||
|
||||
async function getInternalBillingTenancy(): Promise<Tenancy> {
|
||||
const tenancy = await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID, true);
|
||||
if (tenancy == null) {
|
||||
throw new HexclaveAssertionError("Internal billing tenancy not found", {
|
||||
billingProjectId: "internal",
|
||||
branchId: DEFAULT_BRANCH_ID,
|
||||
});
|
||||
}
|
||||
return tenancy;
|
||||
}
|
||||
|
||||
async function countDashboardAdmins(internalTenancy: Tenancy, ownerTeamId: string, now: Date): Promise<number> {
|
||||
const internalPrisma = await getPrismaClientForTenancy(internalTenancy);
|
||||
const [acceptedMembers, pendingInvitations] = await Promise.all([
|
||||
internalPrisma.teamMember.count({
|
||||
where: {
|
||||
tenancyId: internalTenancy.id,
|
||||
teamId: ownerTeamId,
|
||||
},
|
||||
}),
|
||||
globalPrismaClient.verificationCode.count({
|
||||
where: {
|
||||
projectId: internalTenancy.project.id,
|
||||
branchId: internalTenancy.branchId,
|
||||
type: VerificationCodeType.TEAM_INVITATION,
|
||||
usedAt: null,
|
||||
expiresAt: { gt: now },
|
||||
data: {
|
||||
path: ["team_id"],
|
||||
equals: ownerTeamId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return acceptedMembers + pendingInvitations;
|
||||
}
|
||||
|
||||
async function getOwnerTeamDisplayName(internalTenancy: Tenancy, ownerTeamId: string): Promise<string> {
|
||||
const internalPrisma = await getPrismaClientForTenancy(internalTenancy);
|
||||
const team = await internalPrisma.team.findUnique({
|
||||
where: {
|
||||
tenancyId_teamId: {
|
||||
tenancyId: internalTenancy.id,
|
||||
teamId: ownerTeamId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
displayName: true,
|
||||
},
|
||||
});
|
||||
return team?.displayName ?? throwErr(`Owner team ${ownerTeamId} not found in the internal tenancy`);
|
||||
}
|
||||
|
||||
async function countEmailsForTenancy(tenancyId: string, period: UsagePeriod): Promise<number> {
|
||||
const tenancy = await getTenancy(tenancyId) ?? throwErr(`Tenancy ${tenancyId} not found while counting email usage`);
|
||||
const schema = await getPrismaSchemaForTenancy(tenancy);
|
||||
const prisma = await getPrismaClientForTenancy(tenancy);
|
||||
const rows = await prisma.$replica().$queryRaw<[{ count: number }]>`
|
||||
SELECT COUNT(*)::int AS count
|
||||
FROM ${sqlQuoteIdent(schema)}."EmailOutbox"
|
||||
WHERE "tenancyId" = ${tenancy.id}::uuid
|
||||
AND "startedSendingAt" IS NOT NULL
|
||||
AND "startedSendingAt" >= ${period.start}
|
||||
AND "startedSendingAt" < ${period.end}
|
||||
`;
|
||||
return Number(rows[0].count);
|
||||
}
|
||||
|
||||
async function countSessionReplaysForTenancy(tenancyId: string, period: UsagePeriod): Promise<number> {
|
||||
const tenancy = await getTenancy(tenancyId) ?? throwErr(`Tenancy ${tenancyId} not found while counting session replay usage`);
|
||||
const schema = await getPrismaSchemaForTenancy(tenancy);
|
||||
const prisma = await getPrismaClientForTenancy(tenancy);
|
||||
const rows = await prisma.$replica().$queryRaw<[{ count: number }]>`
|
||||
SELECT COUNT(*)::int AS count
|
||||
FROM ${sqlQuoteIdent(schema)}."SessionReplay"
|
||||
WHERE "tenancyId" = ${tenancy.id}::uuid
|
||||
AND "startedAt" >= ${period.start}
|
||||
AND "startedAt" < ${period.end}
|
||||
`;
|
||||
return Number(rows[0].count);
|
||||
}
|
||||
|
||||
async function sumTenancyUsage(tenancyIds: string[], counter: (tenancyId: string) => Promise<number>): Promise<number> {
|
||||
const counts = await Promise.all(tenancyIds.map(counter));
|
||||
return counts.reduce((sum, count) => sum + count, 0);
|
||||
}
|
||||
|
||||
async function countAnalyticsEventsForProjects(projectIds: string[], period: UsagePeriod): Promise<number> {
|
||||
if (projectIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const clickhouseClient = getClickhouseAdminClientForMetrics();
|
||||
const result = await clickhouseClient.query({
|
||||
query: `
|
||||
SELECT count() AS total
|
||||
FROM analytics_internal.events
|
||||
WHERE project_id IN {projectIds:Array(String)}
|
||||
AND event_at >= {periodStart:DateTime}
|
||||
AND event_at < {periodEnd:DateTime}
|
||||
`,
|
||||
query_params: {
|
||||
projectIds,
|
||||
periodStart: formatClickhouseDateTimeParam(period.start),
|
||||
periodEnd: formatClickhouseDateTimeParam(period.end),
|
||||
},
|
||||
format: "JSONEachRow",
|
||||
});
|
||||
const rows: { total: string | number }[] = await result.json();
|
||||
return Number(rows[0]?.total ?? 0);
|
||||
}
|
||||
|
||||
function buildRows(options: {
|
||||
planId: PlanId,
|
||||
dashboardAdmins: number,
|
||||
authUsers: number,
|
||||
emails: number,
|
||||
analyticsEvents: number,
|
||||
sessionReplays: number,
|
||||
}): PlanUsageRow[] {
|
||||
const limits = PLAN_LIMITS[options.planId];
|
||||
return [
|
||||
buildUsageRow({
|
||||
itemId: ITEM_IDS.seats,
|
||||
displayName: getUsageItemLabel(ITEM_IDS.seats),
|
||||
kind: "current",
|
||||
used: options.dashboardAdmins,
|
||||
limit: limits.seats,
|
||||
}),
|
||||
buildUsageRow({
|
||||
itemId: ITEM_IDS.authUsers,
|
||||
displayName: getUsageItemLabel(ITEM_IDS.authUsers),
|
||||
kind: "current",
|
||||
used: options.authUsers,
|
||||
limit: limits.authUsers,
|
||||
}),
|
||||
buildUsageRow({
|
||||
itemId: ITEM_IDS.emailsPerMonth,
|
||||
displayName: getUsageItemLabel(ITEM_IDS.emailsPerMonth),
|
||||
kind: "metered",
|
||||
used: options.emails,
|
||||
limit: limits.emailsPerMonth,
|
||||
}),
|
||||
buildUsageRow({
|
||||
itemId: ITEM_IDS.analyticsEvents,
|
||||
displayName: getUsageItemLabel(ITEM_IDS.analyticsEvents),
|
||||
kind: "metered",
|
||||
used: options.analyticsEvents,
|
||||
limit: limits.analyticsEvents,
|
||||
}),
|
||||
buildUsageRow({
|
||||
itemId: ITEM_IDS.sessionReplays,
|
||||
displayName: getUsageItemLabel(ITEM_IDS.sessionReplays),
|
||||
kind: "metered",
|
||||
used: options.sessionReplays,
|
||||
limit: limits.sessionReplays,
|
||||
}),
|
||||
buildUsageRow({
|
||||
itemId: ITEM_IDS.analyticsTimeoutSeconds,
|
||||
displayName: getUsageItemLabel(ITEM_IDS.analyticsTimeoutSeconds),
|
||||
kind: "capability",
|
||||
used: null,
|
||||
limit: limits.analyticsTimeoutSeconds,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export async function getPlanUsageForProject(project: UsageSourceProject, now: Date = new Date()): Promise<PlanUsageResponse> {
|
||||
const ownerTeamId = getBillingTeamId(project);
|
||||
if (ownerTeamId == null) {
|
||||
throw new HexclaveAssertionError("Project does not have an owner team for plan usage", {
|
||||
projectId: project.id,
|
||||
});
|
||||
}
|
||||
|
||||
const internalTenancy = await getInternalBillingTenancy();
|
||||
const internalPrisma = await getPrismaClientForTenancy(internalTenancy);
|
||||
const subscriptions = await getSubscriptionMapForCustomer({
|
||||
prisma: internalPrisma,
|
||||
tenancyId: internalTenancy.id,
|
||||
customerType: "team",
|
||||
customerId: ownerTeamId,
|
||||
});
|
||||
const activePlanSubscription = resolveActivePlanSubscription(subscriptions);
|
||||
const planId = resolveActivePlanId(activePlanSubscription);
|
||||
const period = getPlanUsagePeriod(activePlanSubscription, now);
|
||||
|
||||
const [ownerTeamDisplayName, ownedProjectIds, ownedTenancyIds, dashboardAdmins, authUsers] = await Promise.all([
|
||||
getOwnerTeamDisplayName(internalTenancy, ownerTeamId),
|
||||
getOwnedProjectIdsForBillingTeam(ownerTeamId),
|
||||
getOwnedTenancyIdsForBillingTeam(ownerTeamId),
|
||||
countDashboardAdmins(internalTenancy, ownerTeamId, now),
|
||||
getTeamWideNonAnonymousUserCount(ownerTeamId),
|
||||
]);
|
||||
|
||||
const [emails, analyticsEvents, sessionReplays] = await Promise.all([
|
||||
sumTenancyUsage(ownedTenancyIds, async (tenancyId) => await countEmailsForTenancy(tenancyId, period)),
|
||||
countAnalyticsEventsForProjects(ownedProjectIds, period),
|
||||
sumTenancyUsage(ownedTenancyIds, async (tenancyId) => await countSessionReplaysForTenancy(tenancyId, period)),
|
||||
]);
|
||||
|
||||
return {
|
||||
owner_team_id: ownerTeamId,
|
||||
owner_team_display_name: ownerTeamDisplayName,
|
||||
plan_id: planId,
|
||||
plan_display_name: activePlanSubscription?.product.displayName ?? getPlanLabel(planId),
|
||||
period_start_millis: period.start.getTime(),
|
||||
period_end_millis: period.end.getTime(),
|
||||
next_plan_id: getNextPlanId(planId),
|
||||
rows: buildRows({
|
||||
planId,
|
||||
dashboardAdmins,
|
||||
authUsers,
|
||||
emails,
|
||||
analyticsEvents,
|
||||
sessionReplays,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,163 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import PageClient from "./page-client";
|
||||
|
||||
const createCheckoutUrlMock = vi.fn();
|
||||
|
||||
function createPlanUsageState() {
|
||||
return {
|
||||
ownerTeamId: "00000000-0000-4000-8000-000000000001",
|
||||
ownerTeamDisplayName: "Acme",
|
||||
planId: "free",
|
||||
planDisplayName: "Free",
|
||||
periodStart: new Date(Date.UTC(2026, 5, 1)),
|
||||
periodEnd: new Date(Date.UTC(2026, 6, 1)),
|
||||
nextPlanId: "team",
|
||||
rows: [
|
||||
{
|
||||
itemId: "dashboard_admins",
|
||||
displayName: "Dashboard admins",
|
||||
kind: "current",
|
||||
used: 1,
|
||||
limit: 1,
|
||||
remaining: 0,
|
||||
overage: 0,
|
||||
isUnlimited: false,
|
||||
},
|
||||
{
|
||||
itemId: "auth_users",
|
||||
displayName: "Auth users",
|
||||
kind: "current",
|
||||
used: 45,
|
||||
limit: null,
|
||||
remaining: null,
|
||||
overage: 0,
|
||||
isUnlimited: true,
|
||||
},
|
||||
{
|
||||
itemId: "analytics_events",
|
||||
displayName: "Analytics events",
|
||||
kind: "metered",
|
||||
used: 120,
|
||||
limit: 100,
|
||||
remaining: 0,
|
||||
overage: 20,
|
||||
isUnlimited: false,
|
||||
},
|
||||
{
|
||||
itemId: "session_replays",
|
||||
displayName: "Session replays",
|
||||
kind: "metered",
|
||||
used: 24,
|
||||
limit: 2500,
|
||||
remaining: 2476,
|
||||
overage: 0,
|
||||
isUnlimited: false,
|
||||
},
|
||||
{
|
||||
itemId: "analytics_timeout_seconds",
|
||||
displayName: "Analytics timeout",
|
||||
kind: "capability",
|
||||
used: null,
|
||||
limit: 10,
|
||||
remaining: null,
|
||||
overage: null,
|
||||
isUnlimited: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
let planUsageState = createPlanUsageState();
|
||||
|
||||
vi.mock("../../use-admin-app", () => ({
|
||||
useAdminApp: () => ({
|
||||
useProject: () => ({
|
||||
id: "project-1",
|
||||
}),
|
||||
usePlanUsage: () => planUsageState,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/dashboard-user", () => ({
|
||||
useDashboardInternalUser: () => ({
|
||||
useTeams: () => [
|
||||
{
|
||||
id: planUsageState.ownerTeamId,
|
||||
createCheckoutUrl: createCheckoutUrlMock,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("Usage settings page", () => {
|
||||
beforeEach(() => {
|
||||
planUsageState = createPlanUsageState();
|
||||
createCheckoutUrlMock.mockReturnValue(new Promise(() => {}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
createCheckoutUrlMock.mockReset();
|
||||
});
|
||||
|
||||
it("renders the plan, usage rows, and overage state", () => {
|
||||
render(<PageClient />);
|
||||
|
||||
// The page title and usage card share this label.
|
||||
expect(screen.getAllByText("Usage").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Free").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Owner").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Dashboard admins").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Authentication").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Auth users").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("45 users · Unlimited")).toBeTruthy();
|
||||
expect(screen.getByText("100% · 20 over")).toBeTruthy();
|
||||
expect(screen.getAllByLabelText("Auth users usage").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Analytics").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Analytics events")).toBeTruthy();
|
||||
expect(screen.getByText("Session replays")).toBeTruthy();
|
||||
expect(screen.getAllByText("Analytics query timeout").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("10s").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("You exceeded your limits. Upgrade to the Team or Growth plan to get higher quotas.").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders zero capped auth users with an empty progress bar", () => {
|
||||
planUsageState = {
|
||||
...createPlanUsageState(),
|
||||
rows: createPlanUsageState().rows.map((row) => row.itemId === "auth_users" ? {
|
||||
...row,
|
||||
used: 0,
|
||||
limit: 10000,
|
||||
remaining: 10000,
|
||||
overage: 0,
|
||||
isUnlimited: false,
|
||||
} : row),
|
||||
};
|
||||
|
||||
render(<PageClient />);
|
||||
|
||||
expect(screen.getByText("0% · 10,000 left")).toBeTruthy();
|
||||
const authUsageBar = screen.getAllByLabelText("Auth users usage")[0];
|
||||
const authUsageFill = authUsageBar.firstElementChild;
|
||||
if (!(authUsageFill instanceof HTMLElement)) {
|
||||
throw new Error("Expected Auth users progress fill element");
|
||||
}
|
||||
expect(authUsageFill.style.width).toBe("0%");
|
||||
});
|
||||
|
||||
it("starts checkout for the next plan from the upgrade CTA", async () => {
|
||||
render(<PageClient />);
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: /^upgrade$/i })[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createCheckoutUrlMock).toHaveBeenCalledWith({
|
||||
productId: "team",
|
||||
returnUrl: window.location.href,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,376 @@
|
||||
"use client";
|
||||
|
||||
import { DesignButton } from "@/components/design-components";
|
||||
import { ALL_APPS_FRONTEND } from "@/lib/apps-frontend";
|
||||
import { useDashboardInternalUser } from "@/lib/dashboard-user";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { throwErr } from "@hexclave/shared/dist/utils/errors";
|
||||
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
|
||||
import { UsersIcon, WarningCircleIcon } from "@phosphor-icons/react";
|
||||
import { useMemo } from "react";
|
||||
import type { ComponentType, SVGProps } from "react";
|
||||
import { PageLayout } from "../../page-layout";
|
||||
import { useAdminApp } from "../../use-admin-app";
|
||||
|
||||
type UsageRow = {
|
||||
itemId: string,
|
||||
displayName: string,
|
||||
kind: "current" | "metered" | "capability",
|
||||
used: number | null,
|
||||
limit: number | null,
|
||||
remaining: number | null,
|
||||
overage: number | null,
|
||||
isUnlimited: boolean,
|
||||
};
|
||||
|
||||
type PlanUsageData = {
|
||||
planDisplayName: string,
|
||||
ownerTeamDisplayName: string,
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
rows: UsageRow[],
|
||||
};
|
||||
|
||||
const AUTH_USERS_ITEM_ID = "auth_users";
|
||||
const DASHBOARD_ADMINS_ITEM_ID = "dashboard_admins";
|
||||
const EMAILS_PER_MONTH_ITEM_ID = "emails_per_month";
|
||||
const ANALYTICS_EVENTS_ITEM_ID = "analytics_events";
|
||||
const SESSION_REPLAYS_ITEM_ID = "session_replays";
|
||||
const ANALYTICS_TIMEOUT_SECONDS_ITEM_ID = "analytics_timeout_seconds";
|
||||
|
||||
type UsageSectionInfo = {
|
||||
id: string,
|
||||
title: string,
|
||||
icon: ComponentType<SVGProps<SVGSVGElement>>,
|
||||
};
|
||||
|
||||
type UsageSection = UsageSectionInfo & {
|
||||
rows: UsageRow[],
|
||||
};
|
||||
|
||||
const DEFAULT_USAGE_SECTION_INFO: UsageSectionInfo = {
|
||||
id: "other",
|
||||
title: "Other",
|
||||
icon: UsersIcon,
|
||||
};
|
||||
|
||||
const USAGE_SECTION_INFO_BY_ITEM_ID = new Map<string, UsageSectionInfo>([
|
||||
[DASHBOARD_ADMINS_ITEM_ID, {
|
||||
id: "dashboard",
|
||||
title: "Dashboard admins",
|
||||
icon: UsersIcon,
|
||||
}],
|
||||
[AUTH_USERS_ITEM_ID, {
|
||||
id: "authentication",
|
||||
title: "Authentication",
|
||||
icon: ALL_APPS_FRONTEND.authentication.icon,
|
||||
}],
|
||||
[EMAILS_PER_MONTH_ITEM_ID, {
|
||||
id: "emails",
|
||||
title: "Emails",
|
||||
icon: ALL_APPS_FRONTEND.emails.icon,
|
||||
}],
|
||||
[ANALYTICS_EVENTS_ITEM_ID, {
|
||||
id: "analytics",
|
||||
title: "Analytics",
|
||||
icon: ALL_APPS_FRONTEND.analytics.icon,
|
||||
}],
|
||||
[SESSION_REPLAYS_ITEM_ID, {
|
||||
id: "analytics",
|
||||
title: "Analytics",
|
||||
icon: ALL_APPS_FRONTEND.analytics.icon,
|
||||
}],
|
||||
]);
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return new Intl.NumberFormat().format(value);
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function formatAnalyticsTimeout(row: UsageRow | undefined): string {
|
||||
if (row == null || row.limit == null) {
|
||||
return "Not included";
|
||||
}
|
||||
return `${formatNumber(row.limit)}s`;
|
||||
}
|
||||
|
||||
function getUsagePercent(row: UsageRow): number | null {
|
||||
if (row.used == null || row.limit == null || row.limit <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.min(100, (row.used / row.limit) * 100);
|
||||
}
|
||||
|
||||
function getUsageSummaryText(row: UsageRow, percent: number | null): string {
|
||||
if (percent != null) {
|
||||
return `${Math.round(percent)}% · ${getRemainingText(row)}`;
|
||||
}
|
||||
if (row.itemId === AUTH_USERS_ITEM_ID && row.used != null) {
|
||||
const usedText = `${formatNumber(row.used)} ${row.used === 1 ? "user" : "users"}`;
|
||||
return row.isUnlimited ? `${usedText} · Unlimited` : usedText;
|
||||
}
|
||||
return getRemainingText(row);
|
||||
}
|
||||
|
||||
function getRemainingText(row: UsageRow): string {
|
||||
if (row.kind === "capability") {
|
||||
return "Plan capability";
|
||||
}
|
||||
if (row.isUnlimited) {
|
||||
return "Unlimited";
|
||||
}
|
||||
if ((row.overage ?? 0) > 0) {
|
||||
return `${formatNumber(row.overage ?? 0)} over`;
|
||||
}
|
||||
return `${formatNumber(row.remaining ?? 0)} left`;
|
||||
}
|
||||
|
||||
function isOverLimit(row: UsageRow): boolean {
|
||||
return (row.overage ?? 0) > 0;
|
||||
}
|
||||
|
||||
function progressBarColor(row: UsageRow, percent: number): string {
|
||||
if (isOverLimit(row)) {
|
||||
return "bg-red-500";
|
||||
}
|
||||
if (percent >= 80) {
|
||||
return "bg-amber-500";
|
||||
}
|
||||
return "bg-emerald-500";
|
||||
}
|
||||
|
||||
function getProgressWidth(row: UsageRow, percent: number | null): string | null {
|
||||
if (percent != null) {
|
||||
const minimumVisiblePercent = row.itemId === AUTH_USERS_ITEM_ID && row.isUnlimited ? 2 : 0;
|
||||
return `${Math.max(minimumVisiblePercent, percent)}%`;
|
||||
}
|
||||
if (row.itemId === AUTH_USERS_ITEM_ID && row.used != null) {
|
||||
return row.isUnlimited ? "1%" : "2%";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function shouldShowUsageRow(row: UsageRow): boolean {
|
||||
return row.itemId !== ANALYTICS_TIMEOUT_SECONDS_ITEM_ID;
|
||||
}
|
||||
|
||||
function getOverageRows(rows: UsageRow[]): UsageRow[] {
|
||||
return rows.filter((row) => (row.overage ?? 0) > 0);
|
||||
}
|
||||
|
||||
function getUsageSectionInfo(row: UsageRow): UsageSectionInfo {
|
||||
return USAGE_SECTION_INFO_BY_ITEM_ID.get(row.itemId) ?? DEFAULT_USAGE_SECTION_INFO;
|
||||
}
|
||||
|
||||
function getUsageSections(rows: UsageRow[]): UsageSection[] {
|
||||
const sections = new Map<string, UsageSection>();
|
||||
for (const row of rows) {
|
||||
const sectionInfo = getUsageSectionInfo(row);
|
||||
const existingSection = sections.get(sectionInfo.id);
|
||||
if (existingSection != null) {
|
||||
existingSection.rows.push(row);
|
||||
} else {
|
||||
sections.set(sectionInfo.id, { ...sectionInfo, rows: [row] });
|
||||
}
|
||||
}
|
||||
const usageSections = [...sections.values()];
|
||||
const dashboardSection = usageSections.find((section) => section.id === "dashboard");
|
||||
if (dashboardSection == null) {
|
||||
return usageSections;
|
||||
}
|
||||
return [dashboardSection, ...usageSections.filter((section) => section.id !== "dashboard")];
|
||||
}
|
||||
|
||||
function UsageMetricLine({ row }: { row: UsageRow }) {
|
||||
const percent = getUsagePercent(row);
|
||||
const progressWidth = getProgressWidth(row, percent);
|
||||
|
||||
return (
|
||||
<div className="pt-3 first:pt-0">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-sm font-medium text-foreground">{row.displayName}</span>
|
||||
<span className={cn(
|
||||
"text-xs tabular-nums",
|
||||
isOverLimit(row) ? "font-semibold text-red-600 dark:text-red-400" : "text-muted-foreground",
|
||||
)}>
|
||||
{getUsageSummaryText(row, percent)}
|
||||
</span>
|
||||
</div>
|
||||
{progressWidth != null && (
|
||||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-foreground/[0.06]" aria-label={`${row.displayName} usage`}>
|
||||
<div className={cn("h-full rounded-full", progressBarColor(row, percent ?? 0))} style={{ width: progressWidth }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageSectionBlock({
|
||||
section,
|
||||
analyticsTimeoutRow,
|
||||
}: {
|
||||
section: UsageSection,
|
||||
analyticsTimeoutRow: UsageRow | undefined,
|
||||
}) {
|
||||
const isAnalytics = section.id === "analytics";
|
||||
const isDashboard = section.id === "dashboard";
|
||||
const Icon = section.icon;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{!isDashboard && (
|
||||
<div className="flex items-center gap-2 border-b border-black/[0.08] pb-1.5 dark:border-white/[0.08]">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">{section.title}</h3>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-3 divide-y divide-black/[0.04] dark:divide-white/[0.04]">
|
||||
{section.rows.map((row) => (
|
||||
<UsageMetricLine key={row.itemId} row={row} />
|
||||
))}
|
||||
{isAnalytics && (
|
||||
<div className="flex items-center justify-between pt-3">
|
||||
<span className="text-sm font-medium text-foreground">Analytics query timeout</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{formatAnalyticsTimeout(analyticsTimeoutRow)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageContent({
|
||||
planUsage,
|
||||
analyticsTimeoutRow,
|
||||
}: {
|
||||
planUsage: PlanUsageData,
|
||||
analyticsTimeoutRow: UsageRow | undefined,
|
||||
}) {
|
||||
const sections = getUsageSections(planUsage.rows);
|
||||
const statItems = [
|
||||
{ label: "Plan", value: planUsage.planDisplayName },
|
||||
{ label: "Billing period", value: `${formatDate(planUsage.periodStart)} – ${formatDate(planUsage.periodEnd)}` },
|
||||
{ label: "Owner", value: planUsage.ownerTeamDisplayName },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{statItems.map((stat) => (
|
||||
<div key={stat.label} className="rounded-xl bg-foreground/[0.03] px-4 py-3">
|
||||
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">{stat.label}</div>
|
||||
<div className="mt-1 truncate text-sm font-medium text-foreground">{stat.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-8 py-2">
|
||||
{sections.map((section) => (
|
||||
<UsageSectionBlock
|
||||
key={section.id}
|
||||
section={section}
|
||||
analyticsTimeoutRow={analyticsTimeoutRow}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageBody({
|
||||
planUsage,
|
||||
analyticsTimeoutRow,
|
||||
onUpgrade,
|
||||
}: {
|
||||
planUsage: PlanUsageData,
|
||||
analyticsTimeoutRow: UsageRow | undefined,
|
||||
onUpgrade: (() => void) | undefined,
|
||||
}) {
|
||||
const overageRows = getOverageRows(planUsage.rows);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{overageRows.length > 0 && (
|
||||
<div
|
||||
role="alert"
|
||||
className="relative grid w-full gap-4 rounded-2xl border border-amber-500/40 bg-amber-500/[0.08] p-4 text-sm sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
|
||||
>
|
||||
<div className="flex min-w-0 gap-3">
|
||||
<WarningCircleIcon className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<div className="min-w-0">
|
||||
<h5 className="mb-1 font-medium leading-none tracking-tight text-amber-700 dark:text-amber-300">
|
||||
Plan limit exceeded
|
||||
</h5>
|
||||
<div className="text-sm text-foreground/80 dark:text-muted-foreground">
|
||||
You exceeded your limits. Upgrade to the Team or Growth plan to get higher quotas.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{onUpgrade != null && (
|
||||
<div className="justify-self-start sm:justify-self-end">
|
||||
<DesignButton onClick={onUpgrade}>
|
||||
Upgrade
|
||||
</DesignButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<UsageContent planUsage={planUsage} analyticsTimeoutRow={analyticsTimeoutRow} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PageClient() {
|
||||
const adminApp = useAdminApp();
|
||||
const project = adminApp.useProject();
|
||||
const planUsage = adminApp.usePlanUsage();
|
||||
const user = useDashboardInternalUser();
|
||||
const teams = user.useTeams();
|
||||
const ownerTeam = useMemo(
|
||||
() => teams.find((team) => team.id === planUsage.ownerTeamId) ?? throwErr(`Owner team ${planUsage.ownerTeamId} not found in user's teams?`, { projectId: project.id, teamIds: teams.map((team) => team.id) }),
|
||||
[planUsage.ownerTeamId, project.id, teams],
|
||||
);
|
||||
const visibleRows = useMemo(
|
||||
() => planUsage.rows.filter(shouldShowUsageRow),
|
||||
[planUsage.rows],
|
||||
);
|
||||
const analyticsTimeoutRow = useMemo(
|
||||
() => planUsage.rows.find((row) => row.itemId === ANALYTICS_TIMEOUT_SECONDS_ITEM_ID),
|
||||
[planUsage.rows],
|
||||
);
|
||||
const planUsageForDisplay = useMemo(
|
||||
() => ({ ...planUsage, rows: visibleRows }),
|
||||
[planUsage, visibleRows],
|
||||
);
|
||||
|
||||
const handleUpgrade = planUsage.nextPlanId == null ? undefined : () => {
|
||||
runAsynchronouslyWithAlert(async () => {
|
||||
const checkoutUrl = await ownerTeam.createCheckoutUrl({
|
||||
productId: planUsage.nextPlanId ?? throwErr("nextPlanId became null unexpectedly"),
|
||||
returnUrl: window.location.href,
|
||||
});
|
||||
window.location.assign(checkoutUrl);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="Usage"
|
||||
description={`Usage for ${planUsage.ownerTeamDisplayName} across all projects owned by this team.`}
|
||||
width={1050}
|
||||
>
|
||||
<UsageBody
|
||||
planUsage={planUsageForDisplay}
|
||||
analyticsTimeoutRow={analyticsTimeoutRow}
|
||||
onUpgrade={handleUpgrade}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import PageClient from "./page-client";
|
||||
|
||||
export default function Page() {
|
||||
return <PageClient />;
|
||||
}
|
||||
@ -136,7 +136,12 @@ const projectSettingsItem: AppSection = {
|
||||
{
|
||||
name: "General",
|
||||
href: "/project-settings",
|
||||
match: (fullUrl: URL) => /^\/projects\/[^\/]+\/project-settings(\/.*)?$/.test(fullUrl.pathname),
|
||||
match: (fullUrl: URL) => /^\/projects\/[^\/]+\/project-settings\/?$/.test(fullUrl.pathname),
|
||||
},
|
||||
{
|
||||
name: "Usage",
|
||||
href: "/project-settings/usage",
|
||||
match: (fullUrl: URL) => /^\/projects\/[^\/]+\/project-settings\/usage(\/.*)?$/.test(fullUrl.pathname),
|
||||
},
|
||||
{
|
||||
name: "Project Keys",
|
||||
|
||||
@ -6,6 +6,7 @@ import type { MoneyAmount } from "../utils/currency-constants";
|
||||
import type { Json } from "../utils/json";
|
||||
import { Result } from "../utils/results";
|
||||
import { urlString } from "../utils/urls";
|
||||
import type { PlanUsageResponse } from "./plan-usage";
|
||||
import type { AnalyticsClickmapDevice, AnalyticsClickmapKind, AnalyticsClickmapResponse, AnalyticsClickmapTokenResponse, MetricsResponse, MetricsUserCounts, UserActivityResponse } from "./admin-metrics";
|
||||
import type { AnalyticsQueryOptions, AnalyticsQueryResponse } from "./crud/analytics";
|
||||
import { EmailOutboxCrud } from "./crud/email-outbox";
|
||||
@ -27,6 +28,8 @@ import { TeamPermissionDefinitionsCrud } from "./crud/team-permissions";
|
||||
import type { Transaction, TransactionType } from "./crud/transactions";
|
||||
import { ServerAuthApplicationOptions, HexclaveServerInterface } from "./server-interface";
|
||||
|
||||
export type { PlanUsageResponse } from "./plan-usage";
|
||||
|
||||
type BranchConfigSourceApi = yup.InferType<typeof branchConfigSourceSchema>;
|
||||
|
||||
export type ChatContent = Array<
|
||||
@ -424,6 +427,17 @@ export class HexclaveAdminInterface extends HexclaveServerInterface {
|
||||
};
|
||||
}
|
||||
|
||||
async getPlanUsage(): Promise<PlanUsageResponse> {
|
||||
const response = await this.sendAdminRequest(
|
||||
"/internal/plan-usage",
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
null,
|
||||
);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async getUserActivity(userId: string): Promise<UserActivityResponse> {
|
||||
const response = await this.sendAdminRequest(
|
||||
urlString`/internal/user-activity?user_id=${userId}`,
|
||||
|
||||
34
packages/shared/src/interface/plan-usage.ts
Normal file
34
packages/shared/src/interface/plan-usage.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import * as yup from "yup";
|
||||
import { ITEM_IDS, PLAN_LIMITS } from "../plans";
|
||||
import { yupArray, yupBoolean, yupNumber, yupObject, yupString } from "../schema-fields";
|
||||
|
||||
const PLAN_IDS = Object.keys(PLAN_LIMITS) as (keyof typeof PLAN_LIMITS)[];
|
||||
const UPGRADE_PLAN_IDS = PLAN_IDS.filter((id): id is Exclude<keyof typeof PLAN_LIMITS, "free"> => id !== "free");
|
||||
|
||||
export const planUsageKindSchema = yupString().oneOf(["current", "metered", "capability"]).defined();
|
||||
|
||||
export const planUsageRowSchema = yupObject({
|
||||
item_id: yupString().oneOf(Object.values(ITEM_IDS)).defined(),
|
||||
display_name: yupString().defined(),
|
||||
kind: planUsageKindSchema,
|
||||
used: yupNumber().integer().nullable().defined(),
|
||||
limit: yupNumber().integer().nullable().defined(),
|
||||
remaining: yupNumber().integer().nullable().defined(),
|
||||
overage: yupNumber().integer().nullable().defined(),
|
||||
is_unlimited: yupBoolean().defined(),
|
||||
}).defined();
|
||||
|
||||
export const planUsageResponseSchema = yupObject({
|
||||
owner_team_id: yupString().uuid().defined(),
|
||||
owner_team_display_name: yupString().defined(),
|
||||
plan_id: yupString().oneOf(PLAN_IDS).defined(),
|
||||
plan_display_name: yupString().defined(),
|
||||
period_start_millis: yupNumber().integer().defined(),
|
||||
period_end_millis: yupNumber().integer().defined(),
|
||||
next_plan_id: yupString().oneOf(UPGRADE_PLAN_IDS).nullable().defined(),
|
||||
rows: yupArray(planUsageRowSchema).defined(),
|
||||
}).defined();
|
||||
|
||||
export type PlanUsageKind = yup.InferType<typeof planUsageKindSchema>;
|
||||
export type PlanUsageRow = yup.InferType<typeof planUsageRowSchema>;
|
||||
export type PlanUsageResponse = yup.InferType<typeof planUsageResponseSchema>;
|
||||
@ -20,6 +20,7 @@ import { EmailConfig, hexclaveAppInternalsSymbol } from "../../common";
|
||||
import { AdminEmailTemplate } from "../../email-templates";
|
||||
import { InternalApiKey, InternalApiKeyBase, InternalApiKeyBaseCrudRead, InternalApiKeyCreateOptions, InternalApiKeyFirstView, internalApiKeyCreateOptionsToCrud } from "../../internal-api-keys";
|
||||
import { AdminProjectPermission, AdminProjectPermissionDefinition, AdminProjectPermissionDefinitionCreateOptions, AdminProjectPermissionDefinitionUpdateOptions, AdminTeamPermission, AdminTeamPermissionDefinition, AdminTeamPermissionDefinitionCreateOptions, AdminTeamPermissionDefinitionUpdateOptions, adminProjectPermissionDefinitionCreateOptionsToCrud, adminProjectPermissionDefinitionUpdateOptionsToCrud, adminTeamPermissionDefinitionCreateOptionsToCrud, adminTeamPermissionDefinitionUpdateOptionsToCrud } from "../../permissions";
|
||||
import type { PlanUsage } from "../../plan-usage";
|
||||
import { AdminOwnedProject, AdminProject, AdminProjectUpdateOptions, PushConfigOptions, adminProjectUpdateOptionsToCrud } from "../../projects";
|
||||
import type { AdminSessionReplay, AdminSessionReplayChunk, ListSessionReplayChunksOptions, ListSessionReplayChunksResult, ListSessionReplaysOptions, ListSessionReplaysResult, SessionReplayAllEventsResult } from "../../session-replays";
|
||||
import { ManagedEmailProviderListItem, ManagedEmailProviderSetupResult, ManagedEmailProviderStatus, EmailOutboxUpdateOptions, StackAdminApp, StackAdminAppConstructorOptions } from "../interfaces/admin-app";
|
||||
@ -33,6 +34,7 @@ import { PushedConfigSource } from "../../projects";
|
||||
import { useAsyncCache } from "./common"; // THIS_LINE_PLATFORM react-like
|
||||
|
||||
type BranchConfigSourceApi = yup.InferType<typeof branchConfigSourceSchema>;
|
||||
type PlanUsageResponse = Awaited<ReturnType<HexclaveAdminInterface["getPlanUsage"]>>;
|
||||
/**
|
||||
* Converts a PushedConfigSource (SDK camelCase) to BranchConfigSourceApi (API snake_case).
|
||||
*/
|
||||
@ -75,6 +77,9 @@ export class _HexclaveAdminAppImplIncomplete<HasTokenStore extends boolean, Proj
|
||||
private readonly _adminProjectCache = createCache(async () => {
|
||||
return await this._interface.getProject();
|
||||
});
|
||||
private readonly _planUsageCache = createCache(async () => {
|
||||
return await this._interface.getPlanUsage();
|
||||
});
|
||||
private readonly _internalApiKeysCache = createCache(async () => {
|
||||
const res = await this._interface.listInternalApiKeys();
|
||||
return res;
|
||||
@ -337,6 +342,28 @@ export class _HexclaveAdminAppImplIncomplete<HasTokenStore extends boolean, Proj
|
||||
};
|
||||
}
|
||||
|
||||
_planUsageFromCrud(data: PlanUsageResponse): PlanUsage {
|
||||
return {
|
||||
ownerTeamId: data.owner_team_id,
|
||||
ownerTeamDisplayName: data.owner_team_display_name,
|
||||
planId: data.plan_id,
|
||||
planDisplayName: data.plan_display_name,
|
||||
periodStart: new Date(data.period_start_millis),
|
||||
periodEnd: new Date(data.period_end_millis),
|
||||
nextPlanId: data.next_plan_id,
|
||||
rows: data.rows.map((row) => ({
|
||||
itemId: row.item_id,
|
||||
displayName: row.display_name,
|
||||
kind: row.kind,
|
||||
used: row.used,
|
||||
limit: row.limit,
|
||||
remaining: row.remaining,
|
||||
overage: row.overage,
|
||||
isUnlimited: row.is_unlimited,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
override async getProject(): Promise<AdminProject> {
|
||||
return this._adminProjectFromCrud(
|
||||
Result.orThrow(await this._adminProjectCache.getOrWait([], "write-only")),
|
||||
@ -354,6 +381,17 @@ export class _HexclaveAdminAppImplIncomplete<HasTokenStore extends boolean, Proj
|
||||
}
|
||||
// END_PLATFORM
|
||||
|
||||
async getPlanUsage(): Promise<PlanUsage> {
|
||||
return this._planUsageFromCrud(Result.orThrow(await this._planUsageCache.getOrWait([], "write-only")));
|
||||
}
|
||||
|
||||
// IF_PLATFORM react-like
|
||||
usePlanUsage(): PlanUsage {
|
||||
const crud = useAsyncCache(this._planUsageCache, [], "adminApp.usePlanUsage()");
|
||||
return useMemo(() => this._planUsageFromCrud(crud), [crud]);
|
||||
}
|
||||
// END_PLATFORM
|
||||
|
||||
protected _createInternalApiKeyBaseFromCrud(data: InternalApiKeyBaseCrudRead): InternalApiKeyBase {
|
||||
const app = this;
|
||||
return {
|
||||
|
||||
@ -9,6 +9,7 @@ import { AsyncStoreProperty, EmailConfig } from "../../common";
|
||||
import { AdminEmailOutbox, AdminSentEmail } from "../../email";
|
||||
import { InternalApiKey, InternalApiKeyCreateOptions, InternalApiKeyFirstView } from "../../internal-api-keys";
|
||||
import { AdminProjectPermission, AdminProjectPermissionDefinition, AdminProjectPermissionDefinitionCreateOptions, AdminProjectPermissionDefinitionUpdateOptions, AdminTeamPermission, AdminTeamPermissionDefinition, AdminTeamPermissionDefinitionCreateOptions, AdminTeamPermissionDefinitionUpdateOptions } from "../../permissions";
|
||||
import type { PlanUsage } from "../../plan-usage";
|
||||
import { AdminProject } from "../../projects";
|
||||
import { _HexclaveAdminAppImpl } from "../implementations";
|
||||
import { StackServerApp, StackServerAppConstructorOptions } from "./server-app";
|
||||
@ -71,6 +72,7 @@ export type StackAdminAppConstructorOptions<HasTokenStore extends boolean, Proje
|
||||
/** @deprecated Use `HexclaveAdminApp` from the `@hexclave/*` package instead — same symbol, new brand name. See https://docs.hexclave.com/migration. */
|
||||
export type StackAdminApp<HasTokenStore extends boolean = boolean, ProjectId extends string = string> = (
|
||||
& AsyncStoreProperty<"project", [], AdminProject, false>
|
||||
& AsyncStoreProperty<"planUsage", [], PlanUsage, false>
|
||||
& AsyncStoreProperty<"internalApiKeys", [], InternalApiKey[], true>
|
||||
& AsyncStoreProperty<"teamPermissionDefinitions", [], AdminTeamPermissionDefinition[], true>
|
||||
& AsyncStoreProperty<"projectPermissionDefinitions", [], AdminProjectPermissionDefinition[], true>
|
||||
|
||||
@ -114,6 +114,14 @@ export type {
|
||||
PushedConfigSource
|
||||
} from "./projects";
|
||||
|
||||
export type {
|
||||
PlanUsage,
|
||||
PlanUsageKind,
|
||||
PlanUsageNextPlanId,
|
||||
PlanUsagePlanId,
|
||||
PlanUsageRow,
|
||||
} from "./plan-usage";
|
||||
|
||||
export type {
|
||||
EditableTeamMemberProfile, ReceivedTeamInvitation,
|
||||
SentTeamInvitation, ServerListUsersOptions,
|
||||
|
||||
25
packages/template/src/lib/hexclave-app/plan-usage/index.ts
Normal file
25
packages/template/src/lib/hexclave-app/plan-usage/index.ts
Normal file
@ -0,0 +1,25 @@
|
||||
export type PlanUsageKind = "current" | "metered" | "capability";
|
||||
export type PlanUsagePlanId = "free" | "team" | "growth";
|
||||
export type PlanUsageNextPlanId = "team" | "growth";
|
||||
|
||||
export type PlanUsageRow = {
|
||||
itemId: string,
|
||||
displayName: string,
|
||||
kind: PlanUsageKind,
|
||||
used: number | null,
|
||||
limit: number | null,
|
||||
remaining: number | null,
|
||||
overage: number | null,
|
||||
isUnlimited: boolean,
|
||||
};
|
||||
|
||||
export type PlanUsage = {
|
||||
ownerTeamId: string,
|
||||
ownerTeamDisplayName: string,
|
||||
planId: PlanUsagePlanId,
|
||||
planDisplayName: string,
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
nextPlanId: PlanUsageNextPlanId | null,
|
||||
rows: PlanUsageRow[],
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user