diff --git a/apps/console/app/components/AppDataStorageUsageTable/AppDataStorageUsageTable.tsx b/apps/console/app/components/AppDataStorageUsageTable/AppDataStorageUsageTable.tsx new file mode 100644 index 0000000000..b125095f30 --- /dev/null +++ b/apps/console/app/components/AppDataStorageUsageTable/AppDataStorageUsageTable.tsx @@ -0,0 +1,220 @@ +import { Text } from '@proofzero/design-system/src/atoms/text/Text' +import { + HiDotsVertical, + HiOutlinePencilAlt, + HiArrowRight, +} from 'react-icons/hi' +import { Link, useFetcher } from '@remix-run/react' +import { Fragment, useEffect, useState } from 'react' +import _ from 'lodash' +import ExternalAppDataPackages from '@proofzero/utils/externalAppDataPackages' +import { FaCheck, FaTimes } from 'react-icons/fa' +import { Menu, Transition } from '@headlessui/react' +import { AppLoaderData } from '~/root' +import AppDataStorageModal from '../AppDataStorageModal/AppDataStorageModal' + +const AppDataStorageUsageTable: React.FC<{ + apps: AppLoaderData[] +}> = ({ apps }) => { + const [isSubscriptionModalOpen, setIsSubscriptionModalOpen] = useState(false) + const [selectedApp, setSelectedApp] = useState() + + const fetcher = useFetcher() + useEffect(() => { + if (fetcher.state === 'idle' && fetcher.type === 'done') { + setIsSubscriptionModalOpen(false) + } + }, [fetcher]) + + return ( + <> + {selectedApp && isSubscriptionModalOpen && ( + setIsSubscriptionModalOpen(false)} + subscriptionFetcher={fetcher} + clientID={selectedApp.clientId!} + currentPackage={ + selectedApp.externalAppDataPackage?.definition.packageDetails + .packageType + } + topUp={selectedApp.externalAppDataPackage?.definition.autoTopUp} + currentPrice={ + selectedApp.externalAppDataPackage?.definition.packageDetails.price + } + reads={selectedApp.externalAppDataPackage?.usage?.readUsage} + writes={selectedApp.externalAppDataPackage?.usage?.writeUsage} + readTopUp={selectedApp.externalAppDataPackage?.usage?.readTopUp} + writeTopUp={selectedApp.externalAppDataPackage?.usage?.writeTopUp} + /> + )} + + {apps.length === 0 ? ( + No apps with usage based billing features + ) : ( + + + + + + + + + + + + {apps.map((app) => ( + + + + + + + + ))} + +
+ + Application + + + + Service + + + + Active packages + + + + Auto top-up + + + + Action + +
+
+ + {app.name} + +
+
+ + App Data Storage + + + + {`${ + ExternalAppDataPackages[ + app.externalAppDataPackage!.definition.packageDetails + .packageType + ].title + } Package`} + + + + {app.externalAppDataPackage!.definition.autoTopUp ? ( + + ) : ( + + )} + + +
+ + +
+ +
+
+ + + +
+
{ + setSelectedApp(app) + setIsSubscriptionModalOpen(true) + }} + className="cursor-pointer" + > + + + + Edit Package + + +
+
+ +
+ + + + + Go to Application + + + +
+
+
+
+
+
+ )} + + ) +} + +export default AppDataStorageUsageTable diff --git a/apps/console/app/root.tsx b/apps/console/app/root.tsx index 3b8714a8cd..08330cccb2 100644 --- a/apps/console/app/root.tsx +++ b/apps/console/app/root.tsx @@ -56,6 +56,8 @@ import { useHydrated } from 'remix-utils' import { getCurrentAndUpcomingInvoices } from './utils/billing' import type { ServicePlanType } from '@proofzero/types/billing' import { registerFeatureFlag } from '@proofzero/design-system/src/hooks/feature-flags' +import { ExternalAppDataPackageDefinition } from '@proofzero/platform.starbase/src/types' +import { GetAppExternalDataUsageOutput } from '@proofzero/platform/starbase/src/jsonrpc/methods/getAppExternalDataUsage' export const links: LinksFunction = () => { return [ @@ -78,6 +80,10 @@ export type AppLoaderData = { hasCustomDomain: boolean groupID?: string groupName?: string + externalAppDataPackage?: { + definition: ExternalAppDataPackageDefinition + usage: GetAppExternalDataUsageOutput + } } export type LoaderData = { @@ -126,6 +132,15 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( coreClient.starbase.listGroupApps.query(), ]) + const allClientIdentifiers = [ + ...apps.map((a) => a.clientId), + ...groupApps.map((a) => a.clientId), + ] + const appsExternalAppDataUsage = + await coreClient.starbase.getAppExternalDataUsageBatch.query( + allClientIdentifiers + ) + const reshapedApps = [ ...apps.map((a) => { return { @@ -136,6 +151,14 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( createdTimestamp: a.createdTimestamp, appPlan: a.appPlan, hasCustomDomain: Boolean(a.customDomain), + externalAppDataPackage: a.externalAppDataPackageDefinition + ? { + definition: a.externalAppDataPackageDefinition, + usage: appsExternalAppDataUsage.find( + (u) => u?.clientID === a.clientId + ), + } + : undefined, } }), ...groupApps.map((a) => ({ @@ -148,6 +171,14 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( hasCustomDomain: Boolean(a.customDomain), groupName: a.groupName, groupID: a.groupURN.split('/')[1], + externalAppDataPackage: a.externalAppDataPackageDefinition + ? { + definition: a.externalAppDataPackageDefinition, + usage: appsExternalAppDataUsage.find( + (u) => u?.clientID === a.clientId + ), + } + : undefined, })), ].sort( (a, b) => @@ -296,6 +327,7 @@ export default function App() { registerFeatureFlag() const paymentFailedIdentityGroupsFetcher = useFetcher() + useEffect(() => { paymentFailedIdentityGroupsFetcher.load( '/api/payment-failed-identity-groups' diff --git a/apps/console/app/routes/__layout/billing/groups/$groupID.tsx b/apps/console/app/routes/__layout/billing/groups/$groupID.tsx index 24c599450b..adb9645c00 100644 --- a/apps/console/app/routes/__layout/billing/groups/$groupID.tsx +++ b/apps/console/app/routes/__layout/billing/groups/$groupID.tsx @@ -40,6 +40,8 @@ import { AppLoaderData } from '~/root' import { GroupSeatingCard } from '~/components/Billing/seating' import { IdentityGroupURN } from '@proofzero/urns/identity-group' import plans from '@proofzero/utils/billing/plans' +import AppDataStorageUsageTable from '~/components/AppDataStorageUsageTable/AppDataStorageUsageTable' +import { useFeatureFlags } from '@proofzero/design-system/src/hooks/feature-flags' export const loader = billingLoader export const action = billingAction @@ -128,7 +130,7 @@ export default () => { ) const hydrated = useHydrated() - + const featureFlags = useFeatureFlags(hydrated) const group = groups.find((g) => g.URN === groupURN) return ( @@ -390,6 +392,23 @@ export default () => { )} + {featureFlags['app_storage'] && + ( +
+
+ + Usage based Services + +
+ + Boolean(a.groupID) && Boolean(a.externalAppDataPackage) + )} + /> +
+ )} +
@@ -448,9 +467,8 @@ export default () => { {invoices.map((invoice, idx) => ( {hydrated && ( @@ -469,16 +487,16 @@ export default () => { {(invoice.status === 'open' || invoice.status === 'uncollectible') && ( -
- - - Payment Error - -
- )} + > + + + Payment Error + + + )} )} @@ -521,36 +539,36 @@ export default () => { )} {(invoice.status === 'open' || invoice.status === 'uncollectible') && ( -
- - - Update Payment - - - -
- )} +
+ + + Update Payment + + + +
+ )} ))} diff --git a/apps/console/app/routes/__layout/billing/personal.tsx b/apps/console/app/routes/__layout/billing/personal.tsx index 1263b29536..7319cf5a3c 100644 --- a/apps/console/app/routes/__layout/billing/personal.tsx +++ b/apps/console/app/routes/__layout/billing/personal.tsx @@ -40,6 +40,8 @@ import { action as billingAction, } from './ops' import plans from '@proofzero/utils/billing/plans' +import AppDataStorageUsageTable from '~/components/AppDataStorageUsageTable/AppDataStorageUsageTable' +import { useFeatureFlags } from '@proofzero/design-system/src/hooks/feature-flags' export const loader = billingLoader export const action = billingAction @@ -129,11 +131,11 @@ export default () => { ) const hydrated = useHydrated() + const featureFlags = useFeatureFlags(hydrated) return ( <> -
{
-
{paymentData && !paymentData.paymentMethodID ? (
@@ -174,7 +175,6 @@ export default () => {
) : null}
-
@@ -315,6 +315,24 @@ export default () => { hasUnpaidInvoices={hasUnpaidInvoices} />
+ {featureFlags['app_storage'] && + ( +
+
+ + Usage based Services + +
+ + !Boolean(a.groupID) && Boolean(a.externalAppDataPackage) + )} + /> +
+ + ) + }
@@ -374,9 +392,8 @@ export default () => { {invoices.map((invoice, idx) => ( {hydrated && ( @@ -395,16 +412,16 @@ export default () => { {(invoice.status === 'open' || invoice.status === 'uncollectible') && ( -
- - - Payment Error - -
- )} + > + + + Payment Error + + + )} )} @@ -447,36 +464,36 @@ export default () => { )} {(invoice.status === 'open' || invoice.status === 'uncollectible') && ( -
- - - Update Payment - - - -
- )} +
+ + + Update Payment + + + +
+ )} ))} diff --git a/apps/console/app/routes/apps/$clientId/billing.tsx b/apps/console/app/routes/apps/$clientId/billing.tsx index b474e8a9f8..8907aee6e8 100644 --- a/apps/console/app/routes/apps/$clientId/billing.tsx +++ b/apps/console/app/routes/apps/$clientId/billing.tsx @@ -69,6 +69,9 @@ import _ from 'lodash' import { FaCheck, FaTimes } from 'react-icons/fa' import { Menu, Transition } from '@headlessui/react' import { ConfirmCancelModal } from './storage.ostrich' +import { useFeatureFlags } from '@proofzero/design-system/src/hooks/feature-flags' +import { useHydrated } from 'remix-utils' + export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper( async ({ request, context, params }) => { @@ -339,7 +342,7 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper( ) { // lots of stripe type casting since by default many // props are strings (not expanded versions) - ;({ status, client_secret, payment_method } = ( + ; ({ status, client_secret, payment_method } = ( sub.latest_invoice as Stripe.Invoice ).payment_intent as Stripe.PaymentIntent) } @@ -718,7 +721,7 @@ const EntitlementsCardButton = ({ const upgrade = isUpgrade(entitlement.planType, currentPlan) const op = entitlement.planType === ServicePlanType.FREE || - getAvailableEntitlements(entitlement) > 0 + getAvailableEntitlements(entitlement) > 0 ? 'update' : 'purchase' @@ -851,6 +854,9 @@ export default () => { } }, [fetcher]) + const hydrated = useHydrated() + const featureFlags = useFeatureFlags(hydrated) + return ( <> {isCancelModalOpen && ( @@ -884,255 +890,258 @@ export default () => { -
- - Usage based Services - - - - - - - - - - - - - - {appDetails.externalAppDataPackageDefinition && - appExternalStorageUsage && ( - - - - + + + )} + {!appExternalStorageUsage && ( + + + + + + + + + + + )} +
- - Applies to service - - - - Unit package - - - - Service status - - - - Usage - - - - Auto top-up - - - - Action - -
-
-
- -
+ {featureFlags['app_storage'] && + ( - - App data storage - -
-
- - {`${ - ExternalAppDataPackages[ - appDetails.externalAppDataPackageDefinition - .packageDetails.packageType - ].title - } Package`} +
+ + Usage based Services + + + + + - + + + + + + {appDetails.externalAppDataPackageDefinition && + appExternalStorageUsage && ( + + + + + + + + - - - )} - {!appExternalStorageUsage && ( - - - - - - - - - - - )} -
+ + Applies to service - - - - {_.upperFirst( - appDetails.externalAppDataPackageDefinition.status - )} + + + + Unit package - - -
- - {`Writes: ${appExternalStorageUsage.writeUsage}/${appExternalStorageUsage.writeAvailable}`} - - - {`Reads: ${appExternalStorageUsage.readUsage}/${appExternalStorageUsage.readAvailable}`} - -
-
- - {appDetails.externalAppDataPackageDefinition.autoTopUp ? ( - - ) : ( - - )} + + + + Service status - - -
- - -
- + +
+ + Usage + + + + Auto top-up + + + + Action + +
+
+
+
- - - - + App data storage + +
+
+ + {`${ExternalAppDataPackages[ + appDetails.externalAppDataPackageDefinition + .packageDetails.packageType + ].title + } Package`} + + + + {_.upperFirst( + appDetails.externalAppDataPackageDefinition.status + )} + + +
+ + {`Writes: ${appExternalStorageUsage.writeUsage}/${appExternalStorageUsage.writeAvailable}`} + + + {`Reads: ${appExternalStorageUsage.readUsage}/${appExternalStorageUsage.readAvailable}`} + +
+
+ + {appDetails.externalAppDataPackageDefinition.autoTopUp ? ( + + ) : ( + + )} + + +
+ + +
+ +
+
+ + + -
-
{ - setIsSubscriptionModalOpen(true) - }} - className="cursor-pointer" > - - - +
{ + setIsSubscriptionModalOpen(true) + }} + className="cursor-pointer" > - Edit Package - - -
-
- -
- + + + Edit Package + + +
+
+ +
+ { - setIsCancelModalOpen(true) - }} - > - - - - Cancel Service - - -
-
-
-
-
-
-
-
- -
- - - App data storage - -
-
- - - - - - - Inactive - - - - - - - - - - - - -
- -
-
-
+ onClick={() => { + setIsCancelModalOpen(true) + }} + > + + + + Cancel Service + + + + + + + +
+
+
+ +
+ + + App data storage + +
+
+ + - + + + + Inactive + + + + - + + + + - + + +
+ +
+
+
+ )}
Plan based Services diff --git a/platform/starbase/src/jsonrpc/methods/getAppExternalDataUsage.ts b/platform/starbase/src/jsonrpc/methods/getAppExternalDataUsage.ts index 230e15b809..7587a759da 100644 --- a/platform/starbase/src/jsonrpc/methods/getAppExternalDataUsage.ts +++ b/platform/starbase/src/jsonrpc/methods/getAppExternalDataUsage.ts @@ -20,9 +20,15 @@ export const GetAppExternalDataUsageOutputSchema = z writeAvailable: z.number(), writeUsage: z.number(), writeTopUp: z.number(), + clientID: z.string(), }) .optional() +export const GetAppExternalDataUsageBatchInputSchema = z.array(z.string()) +export const GetAppExternalDataUsageBatchOutputSchema = z.array( + GetAppExternalDataUsageOutputSchema +) + type GetAppExternalDataUsageInput = z.infer< typeof GetAppExternalDataUsageInputSchema > @@ -30,15 +36,33 @@ export type GetAppExternalDataUsageOutput = z.infer< typeof GetAppExternalDataUsageOutputSchema > +type GetAppExternalDataUsageBatchInput = z.infer< + typeof GetAppExternalDataUsageBatchInputSchema +> +export type GetAppExternalDataUsageBatchOutput = z.infer< + typeof GetAppExternalDataUsageBatchOutputSchema +> + export const getAppExternalDataUsage = async ({ input, ctx, }: { input: GetAppExternalDataUsageInput ctx: Context -}): Promise => { +}): Promise => getUsage(ctx, input.clientId) + +export const getAppExternalDataUsageBatch = async ({ + input, + ctx, +}: { + input: GetAppExternalDataUsageBatchInput + ctx: Context +}): Promise => + Promise.all(input.map((clientId) => getUsage(ctx, clientId))) + +const getUsage = async (ctx: Context, clientID: string) => { const appDO = await getApplicationNodeByClientId( - input.clientId, + clientID, ctx.env.StarbaseApp ) const { externalAppDataPackageDefinition } = await appDO.class.getDetails() @@ -53,12 +77,12 @@ export const getAppExternalDataUsage = async ({ const { packageDetails } = externalAppDataPackageDefinition const externalStorageReadKey = generateUsageKey( - input.clientId, + clientID, UsageCategory.ExternalAppDataRead ) const externalStorageWriteKey = generateUsageKey( - input.clientId, + clientID, UsageCategory.ExternalAppDataWrite ) @@ -78,5 +102,6 @@ export const getAppExternalDataUsage = async ({ writeUsage: writeNumVal, writeTopUp: writeMeta.limit - externalAppDataPackageDefinition.packageDetails.writes, + clientID, } } diff --git a/platform/starbase/src/jsonrpc/router.ts b/platform/starbase/src/jsonrpc/router.ts index 735bd2543c..6b84fb4cde 100644 --- a/platform/starbase/src/jsonrpc/router.ts +++ b/platform/starbase/src/jsonrpc/router.ts @@ -160,6 +160,9 @@ import { } from './methods/externalAppDataUsageReset' import { getAppExternalDataUsage, + getAppExternalDataUsageBatch, + GetAppExternalDataUsageBatchInputSchema, + GetAppExternalDataUsageBatchOutputSchema, GetAppExternalDataUsageInputSchema, GetAppExternalDataUsageOutputSchema, } from './methods/getAppExternalDataUsage' @@ -470,6 +473,15 @@ export const appRouter = t.router({ .input(GetAppExternalDataUsageInputSchema) .output(GetAppExternalDataUsageOutputSchema) .query(getAppExternalDataUsage), + getAppExternalDataUsageBatch: t.procedure + .use(AuthorizationTokenFromHeader) + .use(ValidateJWT) + .use(LogUsage) + .use(Analytics) + .use(OwnAppsMiddleware) + .input(GetAppExternalDataUsageBatchInputSchema) + .output(GetAppExternalDataUsageBatchOutputSchema) + .query(getAppExternalDataUsageBatch), }) export type StarbaseRouter = typeof appRouter