From 219407a09faa556f98f9926ea6b5be24fcfe3c81 Mon Sep 17 00:00:00 2001 From: jusrhee Date: Mon, 6 May 2024 12:53:37 -0400 Subject: [PATCH] Consolidate usage and billing tabs (#4596) Co-authored-by: Mauricio Araujo --- api/server/handlers/billing/plan.go | 50 ++++ api/server/router/project.go | 28 ++ api/types/billing_metronome.go | 35 ++- dashboard/src/components/TabRegion.tsx | 12 +- dashboard/src/lib/billing/types.tsx | 12 +- dashboard/src/lib/hooks/useMetronome.ts | 283 ++++++++++++++++++ .../lib/hooks/{useStripe.tsx => useStripe.ts} | 215 ------------- dashboard/src/main/home/Home.tsx | 3 +- .../home/project-settings/BillingPage.tsx | 214 ++++--------- .../home/project-settings/ProjectSettings.tsx | 15 +- .../main/home/project-settings/UsagePage.tsx | 200 +++++++++++++ dashboard/src/shared/api.tsx | 15 + internal/billing/metronome.go | 63 +++- 13 files changed, 759 insertions(+), 386 deletions(-) create mode 100644 dashboard/src/lib/hooks/useMetronome.ts rename dashboard/src/lib/hooks/{useStripe.tsx => useStripe.ts} (56%) create mode 100644 dashboard/src/main/home/project-settings/UsagePage.tsx diff --git a/api/server/handlers/billing/plan.go b/api/server/handlers/billing/plan.go index 1d5f0ed70d..4d72a6a434 100644 --- a/api/server/handlers/billing/plan.go +++ b/api/server/handlers/billing/plan.go @@ -153,3 +153,53 @@ func (c *ListCustomerUsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ } c.WriteResult(w, r, usage) } + +// ListCustomerCostsHandler returns customer usage aggregations like CPU and RAM hours. +type ListCustomerCostsHandler struct { + handlers.PorterHandlerReadWriter +} + +// NewListCustomerCostsHandler returns a new ListCustomerCostsHandler +func NewListCustomerCostsHandler( + config *config.Config, + decoderValidator shared.RequestDecoderValidator, + writer shared.ResultWriter, +) *ListCustomerCostsHandler { + return &ListCustomerCostsHandler{ + PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer), + } +} + +func (c *ListCustomerCostsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx, span := telemetry.NewSpan(r.Context(), "serve-list-customer-costs") + defer span.End() + + proj, _ := ctx.Value(types.ProjectScope).(*models.Project) + + telemetry.WithAttributes(span, + telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.MetronomeConfigLoaded}, + telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)}, + telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID}, + ) + + if !c.Config().BillingManager.MetronomeConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) { + c.WriteResult(w, r, "") + return + } + + req := &types.ListCustomerCostsRequest{} + + if ok := c.DecodeAndValidate(w, r, req); !ok { + err := telemetry.Error(ctx, span, nil, "error decoding list customer costs request") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) + return + } + + usage, err := c.Config().BillingManager.MetronomeClient.ListCustomerCosts(ctx, proj.UsageID, req.StartingOn, req.EndingBefore, req.Limit) + if err != nil { + err := telemetry.Error(ctx, span, err, "error listing customer costs") + c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) + return + } + c.WriteResult(w, r, usage) +} diff --git a/api/server/router/project.go b/api/server/router/project.go index de7dcfed27..ebecb912b4 100644 --- a/api/server/router/project.go +++ b/api/server/router/project.go @@ -452,6 +452,34 @@ func getProjectRoutes( Router: r, }) + // GET /api/projects/{project_id}/billing/costs -> project.NewListCustomerCostsHandler + listCustomerCostsEndpoint := factory.NewAPIEndpoint( + &types.APIRequestMetadata{ + Verb: types.APIVerbGet, + Method: types.HTTPVerbGet, + Path: &types.Path{ + Parent: basePath, + RelativePath: relPath + "/billing/costs", + }, + Scopes: []types.PermissionScope{ + types.UserScope, + types.ProjectScope, + }, + }, + ) + + listCustomerCostsHandler := billing.NewListCustomerCostsHandler( + config, + factory.GetDecoderValidator(), + factory.GetResultWriter(), + ) + + routes = append(routes, &router.Route{ + Endpoint: listCustomerCostsEndpoint, + Handler: listCustomerCostsHandler, + Router: r, + }) + // GET /api/projects/{project_id}/billing/invoices -> project.NewListCustomerInvoicesHandler listCustomerInvoicesEndpoint := factory.NewAPIEndpoint( &types.APIRequestMetadata{ diff --git a/api/types/billing_metronome.go b/api/types/billing_metronome.go index 2347b504e8..4ce79daf2d 100644 --- a/api/types/billing_metronome.go +++ b/api/types/billing_metronome.go @@ -116,7 +116,40 @@ type BillableMetric struct { Name string `json:"name"` } -// Plan is a pricing plan to which a user is currently subscribed +// ListCustomerCostsRequest is the request to list costs for a customer +type ListCustomerCostsRequest struct { + StartingOn string `schema:"starting_on"` + EndingBefore string `schema:"ending_before"` + Limit int `schema:"limit"` +} + +// Cost is the cost for a customer in a specific time range +type Cost struct { + StartTimestamp string `json:"start_timestamp"` + EndTimestamp string `json:"end_timestamp"` + CreditTypes map[string]CreditTypeCost `json:"credit_types"` +} + +// CreditTypeCost is the cost for a specific credit type (e.g. CPU hours) +type CreditTypeCost struct { + Name string `json:"name"` + Cost float64 `json:"cost"` + LineItemBreakdown []LineItemBreakdownCost `json:"line_item_breakdown"` +} + +// LineItemBreakdownCost is the cost breakdown by line item +type LineItemBreakdownCost struct { + Name string `json:"name"` + Cost float64 `json:"cost"` +} + +// FormattedCost is the cost for a customer in a specific time range, flattened from the Metronome response +type FormattedCost struct { + StartTimestamp string `json:"start_timestamp"` + EndTimestamp string `json:"end_timestamp"` + Cost float64 `json:"cost"` +} + type Plan struct { ID uuid.UUID `json:"id"` PlanID uuid.UUID `json:"plan_id"` diff --git a/dashboard/src/components/TabRegion.tsx b/dashboard/src/components/TabRegion.tsx index 6df49ecb3b..98a5f508d3 100644 --- a/dashboard/src/components/TabRegion.tsx +++ b/dashboard/src/components/TabRegion.tsx @@ -1,13 +1,13 @@ import React, { Component } from "react"; import styled from "styled-components"; -import TabSelector from "./TabSelector"; import Loading from "./Loading"; +import TabSelector from "./TabSelector"; -export interface TabOption { +export type TabOption = { label: string; value: string; -} +}; type PropsType = { options: TabOption[]; @@ -31,7 +31,7 @@ export default class TabRegion extends Component { : ""; componentDidUpdate(prevProps: PropsType) { - let { options, currentTab } = this.props; + const { options, currentTab } = this.props; if (prevProps.options !== options) { if (options.filter((x) => x.value === currentTab).length === 0) { this.props.setCurrentTab(this.defaultTab()); @@ -50,7 +50,9 @@ export default class TabRegion extends Component { options={this.props.options} color={this.props.color} currentTab={this.props.currentTab} - setCurrentTab={(x: string) => this.props.setCurrentTab(x)} + setCurrentTab={(x: string) => { + this.props.setCurrentTab(x); + }} addendum={this.props.addendum} /> diff --git a/dashboard/src/lib/billing/types.tsx b/dashboard/src/lib/billing/types.tsx index 2c251da524..469ba1c9db 100644 --- a/dashboard/src/lib/billing/types.tsx +++ b/dashboard/src/lib/billing/types.tsx @@ -29,10 +29,10 @@ export const PlanValidator = z export type UsageMetric = z.infer; export const UsageMetricValidator = z.object({ - // starting_on and ending_before are ISO 8601 date strings + // starting_on and ending_before are RFC 3339 date strings // that represent the timeframe where the metric was ingested. // If the granularity is set per day, the starting_on field - // represents the dat the metric was ingested. + // represents the day the metric was ingested. starting_on: z.string(), ending_before: z.string(), value: z.number(), @@ -51,6 +51,14 @@ export const CreditGrantsValidator = z.object({ remaining_credits: z.number(), }); +export type CostList = Cost[]; +export type Cost = z.infer; +export const CostValidator = z.object({ + start_timestamp: z.string(), + end_timestamp: z.string(), + cost: z.number(), +}); + export type InvoiceList = Invoice[]; export type Invoice = z.infer; export const InvoiceValidator = z.object({ diff --git a/dashboard/src/lib/hooks/useMetronome.ts b/dashboard/src/lib/hooks/useMetronome.ts new file mode 100644 index 0000000000..a2fd40590f --- /dev/null +++ b/dashboard/src/lib/hooks/useMetronome.ts @@ -0,0 +1,283 @@ +import { useContext } from "react"; +import { useQuery } from "@tanstack/react-query"; + +import { + CostValidator, + CreditGrantsValidator, + InvoiceValidator, + PlanValidator, + ReferralDetailsValidator, + UsageValidator, + type CostList, + type CreditGrants, + type InvoiceList, + type Plan, + type ReferralDetails, + type UsageList, +} from "lib/billing/types"; + +import api from "shared/api"; +import { Context } from "shared/Context"; + +type TGetCredits = { + creditGrants: CreditGrants | null; +}; + +type TGetPlan = { + plan: Plan | null; +}; + +type TGetInvoices = { + invoiceList: InvoiceList | null; +}; + +type TGetUsage = { + usage: UsageList | null; +}; + +type TGetCosts = { + costs: CostList | null; +}; + +type TGetReferralDetails = { + referralDetails: ReferralDetails; +}; + +export const usePorterCredits = (): TGetCredits => { + const { currentProject } = useContext(Context); + + // Fetch available credits + const creditsReq = useQuery( + ["getPorterCredits", currentProject?.id], + async (): Promise => { + if (!currentProject?.metronome_enabled) { + return null; + } + + if (!currentProject?.id || currentProject.id === -1) { + return null; + } + + try { + const res = await api.getPorterCredits( + "", + {}, + { + project_id: currentProject?.id, + } + ); + const creditGrants = CreditGrantsValidator.parse(res.data); + return creditGrants; + } catch (error) { + return null; + } + } + ); + + return { + creditGrants: creditsReq.data ?? null, + }; +}; + +export const useCustomerPlan = (): TGetPlan => { + const { currentProject } = useContext(Context); + + // Fetch current plan + const planReq = useQuery( + ["getCustomerPlan", currentProject?.id], + async (): Promise => { + if (!currentProject?.metronome_enabled) { + return null; + } + + if (!currentProject?.id) { + return null; + } + + try { + const res = await api.getCustomerPlan( + "", + {}, + { project_id: currentProject.id } + ); + + const plan = PlanValidator.parse(res.data); + return plan; + } catch (error) { + return null; + } + } + ); + + return { + plan: planReq.data ?? null, + }; +}; + +export const useCustomerUsage = ( + startingOn: Date | null, + endingBefore: Date | null, + windowSize: string +): TGetUsage => { + const { currentProject } = useContext(Context); + + // Fetch customer usage + const usageReq = useQuery( + ["listCustomerUsage", currentProject?.id], + async (): Promise => { + if (!currentProject?.metronome_enabled) { + return null; + } + + if (!currentProject?.id || currentProject.id === -1) { + return null; + } + + if (startingOn === null || endingBefore === null) { + return null; + } + + try { + const res = await api.getCustomerUsage( + "", + { + starting_on: startingOn.toISOString(), + ending_before: endingBefore.toISOString(), + window_size: windowSize, + }, + { + project_id: currentProject?.id, + } + ); + const usage = UsageValidator.array().parse(res.data); + return usage; + } catch (error) { + return null; + } + } + ); + + return { + usage: usageReq.data ?? null, + }; +}; + +export const useCustomerCosts = ( + startingOn: Date | null, + endingBefore: Date | null, + limit: number +): TGetCosts => { + const { currentProject } = useContext(Context); + + // Fetch customer costs + const usageReq = useQuery( + ["listCustomerCosts", currentProject?.id], + async (): Promise => { + if (!currentProject?.metronome_enabled) { + return null; + } + + if (!currentProject?.id || currentProject.id === -1) { + return null; + } + + if (startingOn === null || endingBefore === null) { + return null; + } + + try { + const res = await api.getCustomerCosts( + "", + {}, + { + project_id: currentProject?.id, + starting_on: startingOn.toISOString(), + ending_before: endingBefore.toISOString(), + limit, + } + ); + + const costs = CostValidator.array().parse(res.data); + return costs; + } catch (error) { + return null; + } + } + ); + + return { + costs: usageReq.data ?? null, + }; +}; + +export const useReferralDetails = (): TGetReferralDetails => { + const { currentProject } = useContext(Context); + + // Fetch user's referral code + const referralsReq = useQuery( + ["getReferralDetails", currentProject?.id], + async (): Promise => { + if (!currentProject?.metronome_enabled) { + return null; + } + + if (!currentProject?.id || currentProject.id === -1) { + return null; + } + + try { + const res = await api.getReferralDetails( + "", + {}, + { project_id: currentProject?.id } + ); + + const referraldetails = ReferralDetailsValidator.parse(res.data); + return referraldetails; + } catch (error) { + return null; + } + } + ); + + return { + referralDetails: referralsReq.data ?? null, + }; +}; + +export const useCustomerInvoices = (): TGetInvoices => { + const { currentProject } = useContext(Context); + + // Fetch customer invoices + const invoicesReq = useQuery( + ["getCustomerInvoices", currentProject?.id], + async (): Promise => { + if (!currentProject?.metronome_enabled) { + return null; + } + + if (!currentProject?.id) { + return null; + } + + try { + const res = await api.getCustomerInvoices( + "", + { + status: "paid", + }, + { project_id: currentProject.id } + ); + + const invoices = InvoiceValidator.array().parse(res.data); + return invoices; + } catch (error) { + return null; + } + } + ); + + return { + invoiceList: invoicesReq.data ?? null, + }; +}; diff --git a/dashboard/src/lib/hooks/useStripe.tsx b/dashboard/src/lib/hooks/useStripe.ts similarity index 56% rename from dashboard/src/lib/hooks/useStripe.tsx rename to dashboard/src/lib/hooks/useStripe.ts index 6c2802ae01..d7299b59da 100644 --- a/dashboard/src/lib/hooks/useStripe.tsx +++ b/dashboard/src/lib/hooks/useStripe.ts @@ -4,19 +4,9 @@ import { z } from "zod"; import { ClientSecretResponse, - CreditGrantsValidator, - InvoiceValidator, PaymentMethodValidator, - PlanValidator, - ReferralDetailsValidator, - UsageValidator, - type CreditGrants, - type InvoiceList, type PaymentMethod, type PaymentMethodList, - type Plan, - type ReferralDetails, - type UsageList, } from "lib/billing/types"; import api from "shared/api"; @@ -52,26 +42,6 @@ type TGetPublishableKey = { publishableKey: string | null; }; -type TGetCredits = { - creditGrants: CreditGrants | null; -}; - -type TGetPlan = { - plan: Plan | null; -}; - -type TGetInvoices = { - invoiceList: InvoiceList | null; -}; - -type TGetUsage = { - usage: UsageList | null; -}; - -type TGetReferralDetails = { - referralDetails: ReferralDetails; -}; - export const usePaymentMethods = (): TUsePaymentMethod => { const { currentProject } = useContext(Context); @@ -269,188 +239,3 @@ export const usePublishableKey = (): TGetPublishableKey => { publishableKey: keyReq.data ?? null, }; }; - -export const usePorterCredits = (): TGetCredits => { - const { currentProject } = useContext(Context); - - // Fetch available credits - const creditsReq = useQuery( - ["getPorterCredits", currentProject?.id], - async (): Promise => { - if (!currentProject?.metronome_enabled) { - return null; - } - - if (!currentProject?.id || currentProject.id === -1) { - return null; - } - - try { - const res = await api.getPorterCredits( - "", - {}, - { - project_id: currentProject?.id, - } - ); - const creditGrants = CreditGrantsValidator.parse(res.data); - return creditGrants; - } catch (error) { - return null; - } - } - ); - - return { - creditGrants: creditsReq.data ?? null, - }; -}; - -export const useCustomerPlan = (): TGetPlan => { - const { currentProject } = useContext(Context); - - // Fetch current plan - const planReq = useQuery( - ["getCustomerPlan", currentProject?.id], - async (): Promise => { - if (!currentProject?.metronome_enabled) { - return null; - } - - if (!currentProject?.id) { - return null; - } - - try { - const res = await api.getCustomerPlan( - "", - {}, - { project_id: currentProject.id } - ); - - const plan = PlanValidator.parse(res.data); - return plan; - } catch (error) { - return null; - } - } - ); - - return { - plan: planReq.data ?? null, - }; -}; - -export const useCustomerUsage = ( - windowSize: string, - currentPeriod: boolean -): TGetUsage => { - const { currentProject } = useContext(Context); - - // Fetch customer usage - const usageReq = useQuery( - ["listCustomerUsage", currentProject?.id], - async (): Promise => { - if (!currentProject?.metronome_enabled) { - return null; - } - - if (!currentProject?.id || currentProject.id === -1) { - return null; - } - - try { - const res = await api.getCustomerUsage( - "", - { - window_size: windowSize, - current_period: currentPeriod, - }, - { - project_id: currentProject?.id, - } - ); - const usage = UsageValidator.array().parse(res.data); - return usage; - } catch (error) { - return null; - } - } - ); - - return { - usage: usageReq.data ?? null, - }; -}; - -export const useReferralDetails = (): TGetReferralDetails => { - const { currentProject } = useContext(Context); - - // Fetch user's referral code - const referralsReq = useQuery( - ["getReferralDetails", currentProject?.id], - async (): Promise => { - if (!currentProject?.metronome_enabled) { - return null; - } - - if (!currentProject?.id || currentProject.id === -1) { - return null; - } - - try { - const res = await api.getReferralDetails( - "", - {}, - { project_id: currentProject?.id } - ); - - const referraldetails = ReferralDetailsValidator.parse(res.data); - return referraldetails; - } catch (error) { - return null; - } - } - ); - - return { - referralDetails: referralsReq.data ?? null, - }; -}; - -export const useCustomerInvoices = (): TGetInvoices => { - const { currentProject } = useContext(Context); - - // Fetch customer invoices - const invoicesReq = useQuery( - ["getCustomerInvoices", currentProject?.id], - async (): Promise => { - if (!currentProject?.metronome_enabled) { - return null; - } - - if (!currentProject?.id) { - return null; - } - - try { - const res = await api.getCustomerInvoices( - "", - { - status: "paid", - }, - { project_id: currentProject.id } - ); - - const invoices = InvoiceValidator.array().parse(res.data); - return invoices; - } catch (error) { - return null; - } - } - ); - - return { - invoiceList: invoicesReq.data ?? null, - }; -}; diff --git a/dashboard/src/main/home/Home.tsx b/dashboard/src/main/home/Home.tsx index d5b6dee76b..7e2b9c33cf 100644 --- a/dashboard/src/main/home/Home.tsx +++ b/dashboard/src/main/home/Home.tsx @@ -18,7 +18,8 @@ import Link from "components/porter/Link"; import Modal from "components/porter/Modal"; import Spacer from "components/porter/Spacer"; import Text from "components/porter/Text"; -import { checkIfProjectHasPayment, useCustomerPlan } from "lib/hooks/useStripe"; +import { useCustomerPlan } from "lib/hooks/useMetronome"; +import { checkIfProjectHasPayment } from "lib/hooks/useStripe"; import api from "shared/api"; import { withAuth, type WithAuthProps } from "shared/auth/AuthorizationHoc"; diff --git a/dashboard/src/main/home/project-settings/BillingPage.tsx b/dashboard/src/main/home/project-settings/BillingPage.tsx index bdecf2dd5a..2ce4b5f49c 100644 --- a/dashboard/src/main/home/project-settings/BillingPage.tsx +++ b/dashboard/src/main/home/project-settings/BillingPage.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useMemo, useState } from "react"; +import React, { useContext, useState } from "react"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; import styled from "styled-components"; @@ -16,13 +16,14 @@ import Modal from "components/porter/Modal"; import Spacer from "components/porter/Spacer"; import Text from "components/porter/Text"; import { - checkIfProjectHasPayment, useCustomerInvoices, useCustomerPlan, - useCustomerUsage, - usePaymentMethods, usePorterCredits, useReferralDetails, +} from "lib/hooks/useMetronome"; +import { + checkIfProjectHasPayment, + usePaymentMethods, useSetDefaultPaymentMethod, } from "lib/hooks/useStripe"; @@ -32,7 +33,6 @@ import gift from "assets/gift.svg"; import trashIcon from "assets/trash.png"; import BillingModal from "../modals/BillingModal"; -import Bars from "./Bars"; dayjs.extend(relativeTime); @@ -57,45 +57,10 @@ function BillingPage(): JSX.Element { const { refetchPaymentEnabled } = checkIfProjectHasPayment(); - const { usage } = useCustomerUsage("day", true); - - const processedData = useMemo(() => { - const before = usage; - const resultMap = new Map(); - - before?.forEach( - (metric: { - metric_name: string; - usage_metrics: Array<{ starting_on: string; value: number }>; - }) => { - const metricName = metric.metric_name.toLowerCase().replace(" ", "_"); - metric.usage_metrics.forEach(({ starting_on, value }) => { - if (resultMap.has(starting_on)) { - resultMap.get(starting_on)[metricName] = value; - } else { - resultMap.set(starting_on, { - starting_on: new Date(starting_on).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - }), - [metricName]: value, - }); - } - }); - } - ); - - // Convert the map to an array of values - const x = Array.from(resultMap.values()); - return x; - }, [usage]); - const formatCredits = (credits: number): string => { return (credits / 100).toFixed(2); }; - const readableDate = (s: string): string => new Date(s).toLocaleDateString(); - const onCreate = async (): Promise => { await refetchPaymentMethods({ throwOnError: false, cancelRefetch: false }); setShouldCreate(false); @@ -250,107 +215,67 @@ function BillingPage(): JSX.Element { - {currentProject?.metronome_enabled && ( -
- {currentProject?.sandbox_enabled && ( -
- Porter credit grants - - - No usage data available for this billing period. - - - - - - - - {creditGrants && creditGrants.remaining_credits > 0 - ? `$${formatCredits( - creditGrants.remaining_credits - )}/$${formatCredits(creditGrants.granted_credits)}` - : "$ 0.00"} - - - -
- )} - -
- Plan Details - - - View the details of the current billing plan of this project. - + Invoice history + + + View all invoices from Porter over the past 12 months. + + + {invoiceList?.map((invoice, i) => { + return ( + <> + + + {dayjs(invoice.created).format("DD/MM/YYYY")} + + + + ); + })} - {plan && plan.plan_name !== "" ? ( -
- Active Plan - -
- - - {plan.plan_name} - - - {plan.trial_info !== undefined && - plan.trial_info.ending_before !== "" ? ( - - Free trial ends{" "} - {dayjs().to(dayjs(plan.trial_info.ending_before))} - - ) : ( - Started on {readableDate(plan.starting_on)} - )} - - -
- - Current Usage - - - View the current usage of this billing period. - - - {usage?.length && - usage.length > 0 && - usage[0].usage_metrics.length > 0 ? ( - - - - - - - - - - ) : ( -
- - No usage data available for this billing period. - -
- )} - -
- ) : ( - This project does not have an active billing plan. - )} -
-
+ {showReferralModal && ( + { + setShowReferralModal(false); + }} + > + Refer users to Porter + + + Earn $10 in free credits for each user you refer to Porter. Referred + users need to connect a payment method for credits to be added to + your account. + + + + + Referral code:{" "} + {currentProject?.referral_code ? ( + {currentProject.referral_code} + ) : ( + "n/a" + )} + + + + Copy referral link + + + + + You have referred{" "} + {referralDetails ? referralDetails.referral_count : "?"}/ + {referralDetails?.max_allowed_referrals} users. + + )} ); @@ -377,17 +302,6 @@ const ReferralCode = styled.div` width: fit-content; `; -const Flex = styled.div` - display: flex; - flex-wrap: wrap; -`; - -const BarWrapper = styled.div` - flex: 1; - height: 300px; - min-width: 450px; -`; - const I = styled.i` font-size: 16px; margin-right: 8px; diff --git a/dashboard/src/main/home/project-settings/ProjectSettings.tsx b/dashboard/src/main/home/project-settings/ProjectSettings.tsx index 42fbd943fa..3c73f0fc93 100644 --- a/dashboard/src/main/home/project-settings/ProjectSettings.tsx +++ b/dashboard/src/main/home/project-settings/ProjectSettings.tsx @@ -29,6 +29,7 @@ import BillingPage from "./BillingPage"; import InvitePage from "./InviteList"; import Metadata from "./Metadata"; import ProjectDeleteConsent from "./ProjectDeleteConsent"; +import UsagePage from "./UsagePage"; type PropsType = RouteComponentProps & WithAuthProps & {}; type ValidationError = { @@ -95,6 +96,16 @@ function ProjectSettings(props: any) { }); } + if ( + currentProject?.billing_enabled && + currentProject?.metronome_enabled + ) { + tabOpts.push({ + value: "usage", + label: "Usage", + }); + } + tabOpts.push({ value: "additional-settings", label: "Additional settings", @@ -166,7 +177,9 @@ function ProjectSettings(props: any) { } else if (currentTab === "api-tokens") { return ; } else if (currentTab === "billing") { - return ; + return ; + } else if (currentTab === "usage") { + return ; } else { return ( <> diff --git a/dashboard/src/main/home/project-settings/UsagePage.tsx b/dashboard/src/main/home/project-settings/UsagePage.tsx new file mode 100644 index 0000000000..39368d6f2e --- /dev/null +++ b/dashboard/src/main/home/project-settings/UsagePage.tsx @@ -0,0 +1,200 @@ +import React, { useMemo, useState } from "react"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import styled from "styled-components"; + +import Fieldset from "components/porter/Fieldset"; +import Select from "components/porter/Select"; +import Spacer from "components/porter/Spacer"; +import Text from "components/porter/Text"; +import { + useCustomerCosts, + useCustomerPlan, + useCustomerUsage, +} from "lib/hooks/useMetronome"; + +import Bars from "./Bars"; + +dayjs.extend(utc); + +function UsagePage(): JSX.Element { + const { plan } = useCustomerPlan(); + + const startDate = dayjs.utc(plan?.starting_on); + const endDate = dayjs().utc().startOf("day"); + const numberOfDays = startDate.daysInMonth(); + + const [currentPeriodStart, setCurrentPeriodStart] = useState( + startDate.toDate() + ); + const [currentPeriodEnd, setCurrentPeriodEnd] = useState(endDate.toDate()); + const [currentPeriodDuration, setCurrentPeriodDuration] = + useState(numberOfDays); + + const { usage } = useCustomerUsage( + currentPeriodStart, + currentPeriodEnd, + "day" + ); + const { costs } = useCustomerCosts( + currentPeriodStart, + currentPeriodEnd, + currentPeriodDuration + ); + let totalCost = 0; + + const processedUsage = useMemo(() => { + const before = usage; + const resultMap = new Map(); + + before?.forEach( + (metric: { + metric_name: string; + usage_metrics: Array<{ starting_on: string; value: number }>; + }) => { + const metricName = metric.metric_name.toLowerCase().replace(" ", "_"); + metric.usage_metrics.forEach(({ starting_on: startingOn, value }) => { + if (resultMap.has(startingOn)) { + resultMap.get(startingOn)[metricName] = value; + } else { + resultMap.set(startingOn, { + starting_on: new Date(startingOn).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }), + [metricName]: value, + }); + } + }); + } + ); + + // Convert the map to an array of values + const x = Array.from(resultMap.values()); + return x; + }, [usage]); + + const processedCosts = useMemo(() => { + return costs + ?.map((dailyCost) => { + dailyCost.start_timestamp = new Date( + dailyCost.start_timestamp + ).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); + dailyCost.cost = parseFloat((dailyCost.cost / 100).toFixed(4)); + totalCost += dailyCost.cost; + return dailyCost; + }) + .filter((dailyCost) => dailyCost.cost > 0); + }, [costs]); + + const generateOptions = (): Array<{ value: string; label: string }> => { + const options = []; + + let startDate = dayjs.utc(currentPeriodStart); + const endDate = dayjs.utc(currentPeriodEnd); + + while (startDate.isBefore(endDate)) { + const nextDate = startDate.add(1, "month"); + options.push({ + value: startDate.format("M-D-YY"), + label: `${startDate.format("M/D/YY")} - ${nextDate.format("M/D/YY")}`, + }); + + startDate = startDate.add(1, "month"); + } + return options; + }; + + const options = generateOptions(); + + return ( + <> +