Преглед изворни кода

Add endpoint for embeddable dashboards

Mauricio Araujo пре 2 година
родитељ
комит
c55309ea0b

+ 55 - 0
api/server/handlers/billing/credits.go

@@ -17,6 +17,11 @@ type GetCreditsHandler struct {
 	handlers.PorterHandlerWriter
 }
 
+// GetUsageDashboardHandler returns an embeddable dashboard to display information related to customer usage.
+type GetUsageDashboardHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
 // NewGetCreditsHandler will create a new GetCreditsHandler
 func NewGetCreditsHandler(
 	config *config.Config,
@@ -27,6 +32,17 @@ func NewGetCreditsHandler(
 	}
 }
 
+// NewGetUsageDashboardHandler returns a new GetUsageDashboardHandler
+func NewGetUsageDashboardHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetUsageDashboardHandler {
+	return &GetUsageDashboardHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
 func (c *GetCreditsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	ctx, span := telemetry.NewSpan(r.Context(), "get-credits-endpoint")
 	defer span.End()
@@ -57,3 +73,42 @@ func (c *GetCreditsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	c.WriteResult(w, r, credits)
 }
+
+func (c *GetUsageDashboardHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "get-usage-dashboard-endpoint")
+	defer span.End()
+
+	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	if !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) {
+		c.WriteResult(w, r, "")
+
+		telemetry.WithAttributes(span,
+			telemetry.AttributeKV{Key: "metronome-enabled", Value: false},
+		)
+		return
+	}
+
+	request := &types.EmbeddableDashboardRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding embeddable usage dashboard request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	credits, err := c.Config().BillingManager.MetronomeClient.GetCustomerDashboard(ctx, proj.UsageID, request.DashboardType)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting customer dashboard")
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "metronome-enabled", Value: true},
+		telemetry.AttributeKV{Key: "project-id", Value: proj.ID},
+		telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID},
+	)
+
+	c.WriteResult(w, r, credits)
+}

+ 28 - 0
api/server/router/project.go

@@ -368,6 +368,34 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/billing/dashboard -> project.NewGetUsageDashboardHandler
+	getUsageDashboardEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/billing/dashboard",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	getUsageDashboardHandler := billing.NewGetUsageDashboardHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getUsageDashboardEndpoint,
+		Handler:  getUsageDashboardHandler,
+		Router:   r,
+	})
+
 	// POST /api/projects/{project_id}/billing/payment_method -> project.NewCreateBillingHandler
 	createBillingEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 23 - 0
api/types/billing_metronome.go

@@ -60,6 +60,29 @@ type ListCreditGrantsRequest struct {
 	EffectiveBefore string `json:"effective_before,omitempty"`
 }
 
+type EmbeddableDashboardRequest struct {
+	// CustomerID is the id of the customer
+	CustomerID uuid.UUID `json:"customer_id,omitempty"`
+	// DashboardType is the type of dashboard to retrieve
+	DashboardType string `json:"dashboard"`
+	// Options are optional dashboard specific options
+	Options []DashboardOptions `json:"dashboard_options,omitempty"`
+	//  ColorOverrides is an optional list of colors to override
+	ColorOverrides []ColorOverrides `json:"color_overrides,omitempty"`
+}
+
+// DashboardOptions are optional dashboard specific options
+type DashboardOptions struct {
+	Key   string
+	Value string
+}
+
+// ColorOverrides is an optional list of colors to override
+type ColorOverrides struct {
+	Name  string
+	Value string
+}
+
 // CreditType is the type of the credit used in the credit grant
 type CreditType struct {
 	Name string `json:"name"`

+ 36 - 0
dashboard/src/lib/hooks/useStripe.tsx

@@ -35,6 +35,10 @@ type TGetPublishableKey = {
   publishableKey: string;
 };
 
+type TGetUsageDashboard = {
+  url: string;
+};
+
 type TGetCredits = {
   credits: number;
 };
@@ -154,6 +158,38 @@ export const checkIfProjectHasPayment = (): TCheckHasPaymentEnabled => {
   };
 };
 
+export const useCustomerDashboard = (dashboard: string): TGetUsageDashboard => {
+  const { currentProject } = useContext(Context);
+
+  // Return an embeddable dashboard for the customer
+  const dashboardReq = useQuery(
+    ["getUsageDashboard", currentProject?.id, dashboard],
+    async () => {
+      if (!currentProject?.id || currentProject.id === -1) {
+        return;
+      }
+      const res = await api.getUsageDashboard(
+        "<token>",
+        {
+          dashboard,
+        },
+        {
+          project_id: currentProject?.id,
+        }
+      );
+      console.log(res);
+      return res.data;
+    },
+    {
+      staleTime: Infinity,
+    }
+  );
+
+  return {
+    url: dashboardReq.data,
+  };
+};
+
 export const usePublishableKey = (): TGetPublishableKey => {
   const { user, currentProject } = useContext(Context);
 

+ 11 - 0
dashboard/src/main/home/project-settings/BillingPage.tsx

@@ -11,6 +11,7 @@ import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import {
   checkIfProjectHasPayment,
+  useCustomerDashboard,
   usePaymentMethods,
   usePorterCredits,
   useSetDefaultPaymentMethod,
@@ -40,6 +41,10 @@ function BillingPage(): JSX.Element {
 
   const { refetchPaymentEnabled } = checkIfProjectHasPayment();
 
+  const { url: creditsDashboard } = useCustomerDashboard("credits");
+  const { url: invoicesDashboard } = useCustomerDashboard("invoices");
+  const { url: usageDashboard } = useCustomerDashboard("usage");
+
   const formatCredits = (credits: number): string => {
     return (credits / 100).toFixed(2);
   };
@@ -80,6 +85,12 @@ function BillingPage(): JSX.Element {
             </Text>
           </Container>
           <Spacer y={2} />
+          <iframe src={creditsDashboard} />
+          <Spacer y={2} />
+          <iframe src={invoicesDashboard} />
+          <Spacer y={2} />
+          <iframe src={usageDashboard} />
+          <Spacer y={2} />
         </div>
       ) : (
         <div></div>

+ 10 - 0
dashboard/src/shared/api.tsx

@@ -3459,6 +3459,15 @@ const getPorterCredits = baseApi<
   }
 >("GET", ({ project_id }) => `/api/projects/${project_id}/billing/credits`);
 
+const getUsageDashboard = baseApi<
+  {
+    dashboard: string;
+  },
+  {
+    project_id?: number;
+  }
+>("POST", ({ project_id }) => `/api/projects/${project_id}/billing/dashboard`);
+
 const getHasBilling = baseApi<{}, { project_id: number }>(
   "GET",
   ({ project_id }) => `/api/projects/${project_id}/billing`
@@ -3857,6 +3866,7 @@ export default {
   // BILLING
   getPublishableKey,
   getPorterCredits,
+  getUsageDashboard,
   listPaymentMethod,
   addPaymentMethod,
   setDefaultPaymentMethod,

+ 36 - 1
internal/billing/metronome.go

@@ -182,6 +182,33 @@ func (m MetronomeClient) GetCustomerCredits(ctx context.Context, customerID uuid
 	return result.Data[0].Balance.IncludingPending, nil
 }
 
+func (m MetronomeClient) GetCustomerDashboard(ctx context.Context, customerID uuid.UUID, dashboardType string) (url string, err error) {
+	ctx, span := telemetry.NewSpan(ctx, "get-customer-usage-dashboard")
+	defer span.End()
+
+	if customerID == uuid.Nil {
+		return url, telemetry.Error(ctx, span, err, "customer id empty")
+	}
+
+	path := "dashboards/getEmbeddableUrl"
+
+	req := types.EmbeddableDashboardRequest{
+		CustomerID:    customerID,
+		DashboardType: dashboardType,
+	}
+
+	var result struct {
+		Data map[string]string `json:"data"`
+	}
+
+	err = post(path, m.ApiKey, req, &result)
+	if err != nil {
+		return url, telemetry.Error(ctx, span, err, "failed to get embeddable dashboard")
+	}
+
+	return result.Data["url"], nil
+}
+
 func post(path string, apiKey string, body interface{}, data interface{}) (err error) {
 	client := http.Client{}
 	endpoint, err := url.JoinPath(metronomeBaseUrl, path)
@@ -211,7 +238,15 @@ func post(path string, apiKey string, body interface{}, data interface{}) (err e
 	}
 
 	if resp.StatusCode != http.StatusOK {
-		return fmt.Errorf("non 200 status code returned: %d", resp.StatusCode)
+		// If there is an error, try to decode the message
+		var message map[string]string
+		err = json.NewDecoder(resp.Body).Decode(&message)
+		if err != nil {
+			return fmt.Errorf("status code %d received, couldn't process response message", resp.StatusCode)
+		}
+		_ = resp.Body.Close()
+
+		return fmt.Errorf("status code %d received, response message: %v", resp.StatusCode, message)
 	}
 
 	if data != nil {