Selaa lähdekoodia

Billing usage endpoints (#4545)

Co-authored-by: jusrhee <justin@porter.run>
Mauricio Araujo 2 vuotta sitten
vanhempi
sitoutus
114b4e1fd8

+ 50 - 0
api/server/handlers/billing/plan.go

@@ -158,3 +158,53 @@ func (c *GetUsageDashboardHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 
 
 	c.WriteResult(w, r, credits)
 	c.WriteResult(w, r, credits)
 }
 }
+
+// ListCustomerUsageHandler returns customer usage aggregations like CPU and RAM hours.
+type ListCustomerUsageHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewListCustomerUsageHandler returns a new ListCustomerUsageHandler
+func NewListCustomerUsageHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ListCustomerUsageHandler {
+	return &ListCustomerUsageHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *ListCustomerUsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-list-customer-usage")
+	defer span.End()
+
+	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.MetronomeEnabled},
+		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.MetronomeEnabled || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) {
+		c.WriteResult(w, r, "")
+		return
+	}
+
+	req := &types.ListCustomerUsageRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, req); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding list customer usage request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	usage, err := c.Config().BillingManager.MetronomeClient.ListCustomerUsage(ctx, proj.UsageID, req.StartingOn, req.EndingBefore, req.WindowSize, req.CurrentPeriod)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error listing customer usage")
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+	c.WriteResult(w, r, usage)
+}

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

@@ -425,6 +425,34 @@ func getProjectRoutes(
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
+	// POST /api/projects/{project_id}/billing/usage -> project.NewListCustomerUsageHandler
+	listCustomerUsageEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/billing/usage",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	listCustomerUsageHandler := billing.NewListCustomerUsageHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listCustomerUsageEndpoint,
+		Handler:  listCustomerUsageHandler,
+		Router:   r,
+	})
+
 	// POST /api/projects/{project_id}/billing/ingest -> project.NewGetUsageDashboardHandler
 	// POST /api/projects/{project_id}/billing/ingest -> project.NewGetUsageDashboardHandler
 	ingestEventsEndpoint := factory.NewAPIEndpoint(
 	ingestEventsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{

+ 30 - 0
api/types/billing_metronome.go

@@ -78,6 +78,36 @@ type EmbeddableDashboardRequest struct {
 	ColorOverrides []ColorOverride `json:"color_overrides,omitempty"`
 	ColorOverrides []ColorOverride `json:"color_overrides,omitempty"`
 }
 }
 
 
+// ListCustomerUsageRequest is the request to list usage for a customer
+type ListCustomerUsageRequest struct {
+	CustomerID       uuid.UUID `json:"customer_id"`
+	BillableMetricID uuid.UUID `json:"billable_metric_id"`
+	WindowSize       string    `json:"window_size"`
+	StartingOn       string    `json:"starting_on,omitempty"`
+	EndingBefore     string    `json:"ending_before,omitempty"`
+	CurrentPeriod    bool      `json:"current_period,omitempty"`
+}
+
+// Usage is the aggregated usage for a customer
+type Usage struct {
+	MetricName   string                `json:"metric_name"`
+	UsageMetrics []CustomerUsageMetric `json:"usage_metrics"`
+}
+
+// CustomerUsageMetric is a metric representing usage for a customer
+type CustomerUsageMetric struct {
+	StartingOn   string  `json:"starting_on"`
+	EndingBefore string  `json:"ending_before"`
+	Value        float64 `json:"value"`
+}
+
+// BillableMetric is defined in Metronome and represents the events that will
+// be ingested
+type BillableMetric struct {
+	ID   uuid.UUID `json:"id"`
+	Name string    `json:"name"`
+}
+
 // Plan is a pricing plan to which a user is currently subscribed
 // Plan is a pricing plan to which a user is currently subscribed
 type Plan struct {
 type Plan struct {
 	ID                  uuid.UUID `json:"id"`
 	ID                  uuid.UUID `json:"id"`

+ 428 - 76
dashboard/package-lock.json

@@ -78,6 +78,7 @@
         "react-table": "^7.7.0",
         "react-table": "^7.7.0",
         "react-timer-hook": "^3.0.7",
         "react-timer-hook": "^3.0.7",
         "react-transition-group": "^4.4.2",
         "react-transition-group": "^4.4.2",
+        "recharts": "^2.12.5",
         "regenerator-runtime": "^0.13.9",
         "regenerator-runtime": "^0.13.9",
         "semver": "^7.3.5",
         "semver": "^7.3.5",
         "simple-statistics": "^7.8.0",
         "simple-statistics": "^7.8.0",
@@ -3264,16 +3265,34 @@
       "integrity": "sha512-hN879HLPTVqZV3FQEXy7ptt083UXwguNbnxdTGzVW4y4KjX5uyNKljrQixZcSJfLyFirbpUokxpXtvR+N5+KIg==",
       "integrity": "sha512-hN879HLPTVqZV3FQEXy7ptt083UXwguNbnxdTGzVW4y4KjX5uyNKljrQixZcSJfLyFirbpUokxpXtvR+N5+KIg==",
       "dev": true
       "dev": true
     },
     },
+    "node_modules/@types/d3-color": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+      "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
+    },
     "node_modules/@types/d3-delaunay": {
     "node_modules/@types/d3-delaunay": {
       "version": "6.0.1",
       "version": "6.0.1",
       "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz",
       "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz",
       "integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ=="
       "integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ=="
     },
     },
+    "node_modules/@types/d3-ease": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+      "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
+    },
     "node_modules/@types/d3-format": {
     "node_modules/@types/d3-format": {
       "version": "3.0.1",
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz",
       "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz",
       "integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg=="
       "integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg=="
     },
     },
+    "node_modules/@types/d3-interpolate": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+      "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+      "dependencies": {
+        "@types/d3-color": "*"
+      }
+    },
     "node_modules/@types/d3-path": {
     "node_modules/@types/d3-path": {
       "version": "1.0.9",
       "version": "1.0.9",
       "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.9.tgz",
       "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.9.tgz",
@@ -3284,6 +3303,14 @@
       "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-2.2.1.tgz",
       "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-2.2.1.tgz",
       "integrity": "sha512-5vvxn6//poNeOxt1ZwC7QU//dG9QqABjy1T7fP/xmFHY95GnaOw3yABf29hiu5SR1Oo34XcpyHFbzod+vemQjA=="
       "integrity": "sha512-5vvxn6//poNeOxt1ZwC7QU//dG9QqABjy1T7fP/xmFHY95GnaOw3yABf29hiu5SR1Oo34XcpyHFbzod+vemQjA=="
     },
     },
+    "node_modules/@types/d3-scale": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz",
+      "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==",
+      "dependencies": {
+        "@types/d3-time": "*"
+      }
+    },
     "node_modules/@types/d3-shape": {
     "node_modules/@types/d3-shape": {
       "version": "1.3.8",
       "version": "1.3.8",
       "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.8.tgz",
       "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.8.tgz",
@@ -3292,12 +3319,22 @@
         "@types/d3-path": "^1"
         "@types/d3-path": "^1"
       }
       }
     },
     },
+    "node_modules/@types/d3-time": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.3.tgz",
+      "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw=="
+    },
     "node_modules/@types/d3-time-format": {
     "node_modules/@types/d3-time-format": {
       "version": "3.0.1",
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-3.0.1.tgz",
       "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-3.0.1.tgz",
       "integrity": "sha512-5GIimz5IqaRsdnxs4YlyTZPwAMfALu/wA4jqSiuqgdbCxUZ2WjrnwANqOtoBJQgeaUTdYNfALJO0Yb0YrDqduA==",
       "integrity": "sha512-5GIimz5IqaRsdnxs4YlyTZPwAMfALu/wA4jqSiuqgdbCxUZ2WjrnwANqOtoBJQgeaUTdYNfALJO0Yb0YrDqduA==",
       "dev": true
       "dev": true
     },
     },
+    "node_modules/@types/d3-timer": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+      "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
+    },
     "node_modules/@types/d3-voronoi": {
     "node_modules/@types/d3-voronoi": {
       "version": "1.1.9",
       "version": "1.1.9",
       "resolved": "https://registry.npmjs.org/@types/d3-voronoi/-/d3-voronoi-1.1.9.tgz",
       "resolved": "https://registry.npmjs.org/@types/d3-voronoi/-/d3-voronoi-1.1.9.tgz",
@@ -4466,48 +4503,6 @@
         "node": ">=12"
         "node": ">=12"
       }
       }
     },
     },
-    "node_modules/@visx/vendor/node_modules/d3-color": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
-      "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/@visx/vendor/node_modules/d3-format": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
-      "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/@visx/vendor/node_modules/d3-interpolate": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
-      "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
-      "dependencies": {
-        "d3-color": "1 - 3"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/@visx/vendor/node_modules/d3-scale": {
-      "version": "4.0.2",
-      "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
-      "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
-      "dependencies": {
-        "d3-array": "2.10.0 - 3",
-        "d3-format": "1 - 3",
-        "d3-interpolate": "1.2.0 - 3",
-        "d3-time": "2.1.1 - 3",
-        "d3-time-format": "2 - 4"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
     "node_modules/@visx/vendor/node_modules/d3-time": {
     "node_modules/@visx/vendor/node_modules/d3-time": {
       "version": "3.1.0",
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
       "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
@@ -6933,6 +6928,14 @@
         "internmap": "^1.0.0"
         "internmap": "^1.0.0"
       }
       }
     },
     },
+    "node_modules/d3-color": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+      "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/d3-delaunay": {
     "node_modules/d3-delaunay": {
       "version": "6.0.2",
       "version": "6.0.2",
       "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.2.tgz",
       "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.2.tgz",
@@ -6944,6 +6947,33 @@
         "node": ">=12"
         "node": ">=12"
       }
       }
     },
     },
+    "node_modules/d3-ease": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+      "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-format": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
+      "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-interpolate": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+      "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+      "dependencies": {
+        "d3-color": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/d3-interpolate-path": {
     "node_modules/d3-interpolate-path": {
       "version": "2.2.1",
       "version": "2.2.1",
       "resolved": "https://registry.npmjs.org/d3-interpolate-path/-/d3-interpolate-path-2.2.1.tgz",
       "resolved": "https://registry.npmjs.org/d3-interpolate-path/-/d3-interpolate-path-2.2.1.tgz",
@@ -6959,6 +6989,21 @@
       "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-2.2.2.tgz",
       "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-2.2.2.tgz",
       "integrity": "sha512-0D9P8TRj6qDAtHhRQn6EfdOtHMfsUWanl3yb/84C4DqpZ+VsgfI5iTVRNRbELCfNvRfpMr8OrqqUTQ6ANGCijw=="
       "integrity": "sha512-0D9P8TRj6qDAtHhRQn6EfdOtHMfsUWanl3yb/84C4DqpZ+VsgfI5iTVRNRbELCfNvRfpMr8OrqqUTQ6ANGCijw=="
     },
     },
+    "node_modules/d3-scale": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+      "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+      "dependencies": {
+        "d3-array": "2.10.0 - 3",
+        "d3-format": "1 - 3",
+        "d3-interpolate": "1.2.0 - 3",
+        "d3-time": "2.1.1 - 3",
+        "d3-time-format": "2 - 4"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/d3-shape": {
     "node_modules/d3-shape": {
       "version": "1.3.7",
       "version": "1.3.7",
       "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz",
       "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz",
@@ -6983,6 +7028,14 @@
         "d3-time": "1 - 2"
         "d3-time": "1 - 2"
       }
       }
     },
     },
+    "node_modules/d3-timer": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+      "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/d3-voronoi": {
     "node_modules/d3-voronoi": {
       "version": "1.1.4",
       "version": "1.1.4",
       "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.4.tgz",
       "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.4.tgz",
@@ -7035,6 +7088,11 @@
         "node": ">=0.10.0"
         "node": ">=0.10.0"
       }
       }
     },
     },
+    "node_modules/decimal.js-light": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+      "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
+    },
     "node_modules/decode-uri-component": {
     "node_modules/decode-uri-component": {
       "version": "0.2.2",
       "version": "0.2.2",
       "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
       "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
@@ -8496,8 +8554,7 @@
     "node_modules/eventemitter3": {
     "node_modules/eventemitter3": {
       "version": "4.0.7",
       "version": "4.0.7",
       "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
       "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
-      "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
-      "dev": true
+      "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
     },
     },
     "node_modules/events": {
     "node_modules/events": {
       "version": "3.3.0",
       "version": "3.3.0",
@@ -8815,6 +8872,14 @@
       "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
       "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
       "dev": true
       "dev": true
     },
     },
+    "node_modules/fast-equals": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.0.1.tgz",
+      "integrity": "sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
     "node_modules/fast-glob": {
     "node_modules/fast-glob": {
       "version": "3.3.1",
       "version": "3.3.1",
       "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
       "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
@@ -13892,6 +13957,20 @@
         "react": ">=15"
         "react": ">=15"
       }
       }
     },
     },
+    "node_modules/react-smooth": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.1.tgz",
+      "integrity": "sha512-OE4hm7XqR0jNOq3Qmk9mFLyd6p2+j6bvbPJ7qlB7+oo0eNcL2l7WQzG6MBnT3EXY6xzkLMUBec3AfewJdA0J8w==",
+      "dependencies": {
+        "fast-equals": "^5.0.1",
+        "prop-types": "^15.8.1",
+        "react-transition-group": "^4.4.5"
+      },
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+        "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+      }
+    },
     "node_modules/react-table": {
     "node_modules/react-table": {
       "version": "7.8.0",
       "version": "7.8.0",
       "resolved": "https://registry.npmjs.org/react-table/-/react-table-7.8.0.tgz",
       "resolved": "https://registry.npmjs.org/react-table/-/react-table-7.8.0.tgz",
@@ -13984,6 +14063,44 @@
         "node": ">=8.10.0"
         "node": ">=8.10.0"
       }
       }
     },
     },
+    "node_modules/recharts": {
+      "version": "2.12.5",
+      "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.12.5.tgz",
+      "integrity": "sha512-Cy+BkqrFIYTHJCyKHJEPvbHE2kVQEP6PKbOHJ8ztRGTAhvHuUnCwDaKVb13OwRFZ0QNUk1QvGTDdgWSMbuMtKw==",
+      "dependencies": {
+        "clsx": "^2.0.0",
+        "eventemitter3": "^4.0.1",
+        "lodash": "^4.17.21",
+        "react-is": "^16.10.2",
+        "react-smooth": "^4.0.0",
+        "recharts-scale": "^0.4.4",
+        "tiny-invariant": "^1.3.1",
+        "victory-vendor": "^36.6.8"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "peerDependencies": {
+        "react": "^16.0.0 || ^17.0.0 || ^18.0.0",
+        "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0"
+      }
+    },
+    "node_modules/recharts-scale": {
+      "version": "0.4.5",
+      "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
+      "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
+      "dependencies": {
+        "decimal.js-light": "^2.4.1"
+      }
+    },
+    "node_modules/recharts/node_modules/clsx": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz",
+      "integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/redent": {
     "node_modules/redent": {
       "version": "3.0.0",
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
       "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -16605,6 +16722,81 @@
         "node": ">= 0.8"
         "node": ">= 0.8"
       }
       }
     },
     },
+    "node_modules/victory-vendor": {
+      "version": "36.9.2",
+      "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
+      "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
+      "dependencies": {
+        "@types/d3-array": "^3.0.3",
+        "@types/d3-ease": "^3.0.0",
+        "@types/d3-interpolate": "^3.0.1",
+        "@types/d3-scale": "^4.0.2",
+        "@types/d3-shape": "^3.1.0",
+        "@types/d3-time": "^3.0.0",
+        "@types/d3-timer": "^3.0.0",
+        "d3-array": "^3.1.6",
+        "d3-ease": "^3.0.1",
+        "d3-interpolate": "^3.0.1",
+        "d3-scale": "^4.0.2",
+        "d3-shape": "^3.1.0",
+        "d3-time": "^3.0.0",
+        "d3-timer": "^3.0.1"
+      }
+    },
+    "node_modules/victory-vendor/node_modules/@types/d3-array": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
+      "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="
+    },
+    "node_modules/victory-vendor/node_modules/@types/d3-shape": {
+      "version": "3.1.6",
+      "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz",
+      "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==",
+      "dependencies": {
+        "@types/d3-path": "*"
+      }
+    },
+    "node_modules/victory-vendor/node_modules/d3-array": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+      "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+      "dependencies": {
+        "internmap": "1 - 2"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/victory-vendor/node_modules/d3-path": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+      "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/victory-vendor/node_modules/d3-shape": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+      "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+      "dependencies": {
+        "d3-path": "^3.1.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/victory-vendor/node_modules/d3-time": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+      "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+      "dependencies": {
+        "d3-array": "2 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/vm-browserify": {
     "node_modules/vm-browserify": {
       "version": "1.1.2",
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz",
       "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz",
@@ -20459,16 +20651,34 @@
       "integrity": "sha512-hN879HLPTVqZV3FQEXy7ptt083UXwguNbnxdTGzVW4y4KjX5uyNKljrQixZcSJfLyFirbpUokxpXtvR+N5+KIg==",
       "integrity": "sha512-hN879HLPTVqZV3FQEXy7ptt083UXwguNbnxdTGzVW4y4KjX5uyNKljrQixZcSJfLyFirbpUokxpXtvR+N5+KIg==",
       "dev": true
       "dev": true
     },
     },
+    "@types/d3-color": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+      "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
+    },
     "@types/d3-delaunay": {
     "@types/d3-delaunay": {
       "version": "6.0.1",
       "version": "6.0.1",
       "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz",
       "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz",
       "integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ=="
       "integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ=="
     },
     },
+    "@types/d3-ease": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+      "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
+    },
     "@types/d3-format": {
     "@types/d3-format": {
       "version": "3.0.1",
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz",
       "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz",
       "integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg=="
       "integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg=="
     },
     },
+    "@types/d3-interpolate": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+      "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+      "requires": {
+        "@types/d3-color": "*"
+      }
+    },
     "@types/d3-path": {
     "@types/d3-path": {
       "version": "1.0.9",
       "version": "1.0.9",
       "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.9.tgz",
       "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.9.tgz",
@@ -20479,6 +20689,14 @@
       "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-2.2.1.tgz",
       "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-2.2.1.tgz",
       "integrity": "sha512-5vvxn6//poNeOxt1ZwC7QU//dG9QqABjy1T7fP/xmFHY95GnaOw3yABf29hiu5SR1Oo34XcpyHFbzod+vemQjA=="
       "integrity": "sha512-5vvxn6//poNeOxt1ZwC7QU//dG9QqABjy1T7fP/xmFHY95GnaOw3yABf29hiu5SR1Oo34XcpyHFbzod+vemQjA=="
     },
     },
+    "@types/d3-scale": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz",
+      "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==",
+      "requires": {
+        "@types/d3-time": "*"
+      }
+    },
     "@types/d3-shape": {
     "@types/d3-shape": {
       "version": "1.3.8",
       "version": "1.3.8",
       "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.8.tgz",
       "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.8.tgz",
@@ -20487,12 +20705,22 @@
         "@types/d3-path": "^1"
         "@types/d3-path": "^1"
       }
       }
     },
     },
+    "@types/d3-time": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.3.tgz",
+      "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw=="
+    },
     "@types/d3-time-format": {
     "@types/d3-time-format": {
       "version": "3.0.1",
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-3.0.1.tgz",
       "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-3.0.1.tgz",
       "integrity": "sha512-5GIimz5IqaRsdnxs4YlyTZPwAMfALu/wA4jqSiuqgdbCxUZ2WjrnwANqOtoBJQgeaUTdYNfALJO0Yb0YrDqduA==",
       "integrity": "sha512-5GIimz5IqaRsdnxs4YlyTZPwAMfALu/wA4jqSiuqgdbCxUZ2WjrnwANqOtoBJQgeaUTdYNfALJO0Yb0YrDqduA==",
       "dev": true
       "dev": true
     },
     },
+    "@types/d3-timer": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+      "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
+    },
     "@types/d3-voronoi": {
     "@types/d3-voronoi": {
       "version": "1.1.9",
       "version": "1.1.9",
       "resolved": "https://registry.npmjs.org/@types/d3-voronoi/-/d3-voronoi-1.1.9.tgz",
       "resolved": "https://registry.npmjs.org/@types/d3-voronoi/-/d3-voronoi-1.1.9.tgz",
@@ -21490,36 +21718,6 @@
             "internmap": "1 - 2"
             "internmap": "1 - 2"
           }
           }
         },
         },
-        "d3-color": {
-          "version": "3.1.0",
-          "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
-          "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="
-        },
-        "d3-format": {
-          "version": "3.1.0",
-          "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
-          "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="
-        },
-        "d3-interpolate": {
-          "version": "3.0.1",
-          "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
-          "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
-          "requires": {
-            "d3-color": "1 - 3"
-          }
-        },
-        "d3-scale": {
-          "version": "4.0.2",
-          "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
-          "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
-          "requires": {
-            "d3-array": "2.10.0 - 3",
-            "d3-format": "1 - 3",
-            "d3-interpolate": "1.2.0 - 3",
-            "d3-time": "2.1.1 - 3",
-            "d3-time-format": "2 - 4"
-          }
-        },
         "d3-time": {
         "d3-time": {
           "version": "3.1.0",
           "version": "3.1.0",
           "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
           "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
@@ -23512,6 +23710,11 @@
         "internmap": "^1.0.0"
         "internmap": "^1.0.0"
       }
       }
     },
     },
+    "d3-color": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+      "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="
+    },
     "d3-delaunay": {
     "d3-delaunay": {
       "version": "6.0.2",
       "version": "6.0.2",
       "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.2.tgz",
       "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.2.tgz",
@@ -23520,6 +23723,24 @@
         "delaunator": "5"
         "delaunator": "5"
       }
       }
     },
     },
+    "d3-ease": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+      "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="
+    },
+    "d3-format": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
+      "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="
+    },
+    "d3-interpolate": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+      "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+      "requires": {
+        "d3-color": "1 - 3"
+      }
+    },
     "d3-interpolate-path": {
     "d3-interpolate-path": {
       "version": "2.2.1",
       "version": "2.2.1",
       "resolved": "https://registry.npmjs.org/d3-interpolate-path/-/d3-interpolate-path-2.2.1.tgz",
       "resolved": "https://registry.npmjs.org/d3-interpolate-path/-/d3-interpolate-path-2.2.1.tgz",
@@ -23535,6 +23756,18 @@
       "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-2.2.2.tgz",
       "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-2.2.2.tgz",
       "integrity": "sha512-0D9P8TRj6qDAtHhRQn6EfdOtHMfsUWanl3yb/84C4DqpZ+VsgfI5iTVRNRbELCfNvRfpMr8OrqqUTQ6ANGCijw=="
       "integrity": "sha512-0D9P8TRj6qDAtHhRQn6EfdOtHMfsUWanl3yb/84C4DqpZ+VsgfI5iTVRNRbELCfNvRfpMr8OrqqUTQ6ANGCijw=="
     },
     },
+    "d3-scale": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+      "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+      "requires": {
+        "d3-array": "2.10.0 - 3",
+        "d3-format": "1 - 3",
+        "d3-interpolate": "1.2.0 - 3",
+        "d3-time": "2.1.1 - 3",
+        "d3-time-format": "2 - 4"
+      }
+    },
     "d3-shape": {
     "d3-shape": {
       "version": "1.3.7",
       "version": "1.3.7",
       "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz",
       "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz",
@@ -23559,6 +23792,11 @@
         "d3-time": "1 - 2"
         "d3-time": "1 - 2"
       }
       }
     },
     },
+    "d3-timer": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+      "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="
+    },
     "d3-voronoi": {
     "d3-voronoi": {
       "version": "1.1.4",
       "version": "1.1.4",
       "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.4.tgz",
       "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.4.tgz",
@@ -23593,6 +23831,11 @@
       "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
       "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
       "dev": true
       "dev": true
     },
     },
+    "decimal.js-light": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+      "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
+    },
     "decode-uri-component": {
     "decode-uri-component": {
       "version": "0.2.2",
       "version": "0.2.2",
       "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
       "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
@@ -24715,8 +24958,7 @@
     "eventemitter3": {
     "eventemitter3": {
       "version": "4.0.7",
       "version": "4.0.7",
       "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
       "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
-      "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
-      "dev": true
+      "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
     },
     },
     "events": {
     "events": {
       "version": "3.3.0",
       "version": "3.3.0",
@@ -24988,6 +25230,11 @@
       "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
       "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
       "dev": true
       "dev": true
     },
     },
+    "fast-equals": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.0.1.tgz",
+      "integrity": "sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ=="
+    },
     "fast-glob": {
     "fast-glob": {
       "version": "3.3.1",
       "version": "3.3.1",
       "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
       "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
@@ -28825,6 +29072,16 @@
         "tiny-warning": "^1.0.0"
         "tiny-warning": "^1.0.0"
       }
       }
     },
     },
+    "react-smooth": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.1.tgz",
+      "integrity": "sha512-OE4hm7XqR0jNOq3Qmk9mFLyd6p2+j6bvbPJ7qlB7+oo0eNcL2l7WQzG6MBnT3EXY6xzkLMUBec3AfewJdA0J8w==",
+      "requires": {
+        "fast-equals": "^5.0.1",
+        "prop-types": "^15.8.1",
+        "react-transition-group": "^4.4.5"
+      }
+    },
     "react-table": {
     "react-table": {
       "version": "7.8.0",
       "version": "7.8.0",
       "resolved": "https://registry.npmjs.org/react-table/-/react-table-7.8.0.tgz",
       "resolved": "https://registry.npmjs.org/react-table/-/react-table-7.8.0.tgz",
@@ -28898,6 +29155,36 @@
         "picomatch": "^2.2.1"
         "picomatch": "^2.2.1"
       }
       }
     },
     },
+    "recharts": {
+      "version": "2.12.5",
+      "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.12.5.tgz",
+      "integrity": "sha512-Cy+BkqrFIYTHJCyKHJEPvbHE2kVQEP6PKbOHJ8ztRGTAhvHuUnCwDaKVb13OwRFZ0QNUk1QvGTDdgWSMbuMtKw==",
+      "requires": {
+        "clsx": "^2.0.0",
+        "eventemitter3": "^4.0.1",
+        "lodash": "^4.17.21",
+        "react-is": "^16.10.2",
+        "react-smooth": "^4.0.0",
+        "recharts-scale": "^0.4.4",
+        "tiny-invariant": "^1.3.1",
+        "victory-vendor": "^36.6.8"
+      },
+      "dependencies": {
+        "clsx": {
+          "version": "2.1.0",
+          "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz",
+          "integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg=="
+        }
+      }
+    },
+    "recharts-scale": {
+      "version": "0.4.5",
+      "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
+      "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
+      "requires": {
+        "decimal.js-light": "^2.4.1"
+      }
+    },
     "redent": {
     "redent": {
       "version": "3.0.0",
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
       "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -30952,6 +31239,71 @@
       "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
       "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
       "dev": true
       "dev": true
     },
     },
+    "victory-vendor": {
+      "version": "36.9.2",
+      "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
+      "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
+      "requires": {
+        "@types/d3-array": "^3.0.3",
+        "@types/d3-ease": "^3.0.0",
+        "@types/d3-interpolate": "^3.0.1",
+        "@types/d3-scale": "^4.0.2",
+        "@types/d3-shape": "^3.1.0",
+        "@types/d3-time": "^3.0.0",
+        "@types/d3-timer": "^3.0.0",
+        "d3-array": "^3.1.6",
+        "d3-ease": "^3.0.1",
+        "d3-interpolate": "^3.0.1",
+        "d3-scale": "^4.0.2",
+        "d3-shape": "^3.1.0",
+        "d3-time": "^3.0.0",
+        "d3-timer": "^3.0.1"
+      },
+      "dependencies": {
+        "@types/d3-array": {
+          "version": "3.2.1",
+          "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
+          "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="
+        },
+        "@types/d3-shape": {
+          "version": "3.1.6",
+          "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz",
+          "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==",
+          "requires": {
+            "@types/d3-path": "*"
+          }
+        },
+        "d3-array": {
+          "version": "3.2.4",
+          "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+          "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+          "requires": {
+            "internmap": "1 - 2"
+          }
+        },
+        "d3-path": {
+          "version": "3.1.0",
+          "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+          "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="
+        },
+        "d3-shape": {
+          "version": "3.2.0",
+          "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+          "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+          "requires": {
+            "d3-path": "^3.1.0"
+          }
+        },
+        "d3-time": {
+          "version": "3.1.0",
+          "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+          "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+          "requires": {
+            "d3-array": "2 - 3"
+          }
+        }
+      }
+    },
     "vm-browserify": {
     "vm-browserify": {
       "version": "1.1.2",
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz",
       "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz",

+ 1 - 0
dashboard/package.json

@@ -73,6 +73,7 @@
     "react-table": "^7.7.0",
     "react-table": "^7.7.0",
     "react-timer-hook": "^3.0.7",
     "react-timer-hook": "^3.0.7",
     "react-transition-group": "^4.4.2",
     "react-transition-group": "^4.4.2",
+    "recharts": "^2.12.5",
     "regenerator-runtime": "^0.13.9",
     "regenerator-runtime": "^0.13.9",
     "semver": "^7.3.5",
     "semver": "^7.3.5",
     "simple-statistics": "^7.8.0",
     "simple-statistics": "^7.8.0",

+ 7 - 8
dashboard/src/components/porter/Fieldset.tsx

@@ -4,12 +4,10 @@ import styled from "styled-components";
 type Props = {
 type Props = {
   children: React.ReactNode;
   children: React.ReactNode;
   background?: string;
   background?: string;
+  row?: boolean;
 };
 };
 
 
-const Fieldset: React.FC<Props> = ({
-  children,
-  background,
-}) => {
+const Fieldset: React.FC<Props> = ({ children, background, row }) => {
   const getBackground = () => {
   const getBackground = () => {
     switch (background) {
     switch (background) {
       case "dark":
       case "dark":
@@ -19,7 +17,7 @@ const Fieldset: React.FC<Props> = ({
     }
     }
   };
   };
   return (
   return (
-    <StyledFieldset background={getBackground()}>
+    <StyledFieldset background={getBackground()} row={row}>
       {children}
       {children}
     </StyledFieldset>
     </StyledFieldset>
   );
   );
@@ -29,11 +27,12 @@ export default Fieldset;
 
 
 const StyledFieldset = styled.div<{
 const StyledFieldset = styled.div<{
   background?: string;
   background?: string;
+  row?: boolean;
 }>`
 }>`
   position: relative;
   position: relative;
-  padding: 25px;
+  padding: ${(props) => (props.row ? "15px 20px" : "25px")};
   border-radius: 5px;
   border-radius: 5px;
-  background: ${props => props.background || props.theme.fg};
+  background: ${(props) => props.background || props.theme.fg};
   border: 1px solid #494b4f;
   border: 1px solid #494b4f;
   font-size: 13px;
   font-size: 13px;
-`;
+`;

+ 22 - 6
dashboard/src/lib/billing/types.tsx

@@ -12,19 +12,35 @@ export const PaymentMethodValidator = z.object({
   is_default: z.boolean(),
   is_default: z.boolean(),
 });
 });
 
 
-type Trial = z.infer<typeof Trial>;
-
-const Trial = z.object({
+const TrialValidator = z.object({
   ending_before: z.string(),
   ending_before: z.string(),
 });
 });
 
 
-export type Plan = z.infer<typeof Plan>;
-export const Plan = z.object({
+export type Plan = z.infer<typeof PlanValidator>;
+export const PlanValidator = z.object({
   id: z.string(),
   id: z.string(),
   plan_name: z.string(),
   plan_name: z.string(),
   plan_description: z.string(),
   plan_description: z.string(),
   starting_on: z.string(),
   starting_on: z.string(),
-  trial_info: Trial,
+  trial_info: TrialValidator,
+});
+
+export type UsageMetric = z.infer<typeof UsageMetricValidator>;
+export const UsageMetricValidator = z.object({
+  // starting_on and ending_before are ISO 8601 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.
+  starting_on: z.string(),
+  ending_before: z.string(),
+  value: z.number(),
+});
+
+export type UsageList = Usage[];
+export type Usage = z.infer<typeof UsageValidator>;
+export const UsageValidator = z.object({
+  metric_name: z.string(),
+  usage_metrics: z.array(UsageMetricValidator),
 });
 });
 
 
 export type CreditGrants = z.infer<typeof CreditGrantsValidator>;
 export type CreditGrants = z.infer<typeof CreditGrantsValidator>;

+ 41 - 2
dashboard/src/lib/hooks/useStripe.tsx

@@ -6,10 +6,12 @@ import {
   ClientSecretResponse,
   ClientSecretResponse,
   CreditGrantsValidator,
   CreditGrantsValidator,
   PaymentMethodValidator,
   PaymentMethodValidator,
-  Plan,
+  PlanValidator,
+  UsageValidator,
   type CreditGrants,
   type CreditGrants,
   type PaymentMethod,
   type PaymentMethod,
   type PaymentMethodList,
   type PaymentMethodList,
+  type UsageList,
 } from "lib/billing/types";
 } from "lib/billing/types";
 
 
 import api from "shared/api";
 import api from "shared/api";
@@ -57,6 +59,10 @@ type TGetPlan = {
   plan: Plan | undefined;
   plan: Plan | undefined;
 };
 };
 
 
+type TGetUsage = {
+  usage: UsageList | undefined;
+};
+
 const embeddableDashboardColors = {
 const embeddableDashboardColors = {
   grayDark: "Gray_dark",
   grayDark: "Gray_dark",
   grayMedium: "Gray_medium",
   grayMedium: "Gray_medium",
@@ -297,7 +303,7 @@ export const useCustomerPlan = (): TGetPlan => {
           project_id: currentProject?.id,
           project_id: currentProject?.id,
         }
         }
       );
       );
-      const plan = Plan.parse(res.data);
+      const plan = PlanValidator.parse(res.data);
       return plan;
       return plan;
     }
     }
   );
   );
@@ -307,6 +313,39 @@ export const useCustomerPlan = (): TGetPlan => {
   };
   };
 };
 };
 
 
+export const useCustomerUsage = (
+  windowSize: string,
+  currentPeriod: boolean
+): TGetUsage => {
+  const { currentProject } = useContext(Context);
+
+  // Fetch customer usage
+  const usageReq = useQuery(
+    ["listCustomerUsage", currentProject?.id],
+    async () => {
+      if (!currentProject?.id || currentProject.id === -1) {
+        return;
+      }
+      const res = await api.getCustomerUsage(
+        "<token>",
+        {
+          window_size: windowSize,
+          current_period: currentPeriod,
+        },
+        {
+          project_id: currentProject?.id,
+        }
+      );
+      const usage = UsageValidator.array().parse(res.data);
+      return usage;
+    }
+  );
+
+  return {
+    usage: usageReq.data,
+  };
+};
+
 export const useSetDefaultPaymentMethod = (): TSetDefaultPaymentMethod => {
 export const useSetDefaultPaymentMethod = (): TSetDefaultPaymentMethod => {
   const { currentProject } = useContext(Context);
   const { currentProject } = useContext(Context);
 
 

+ 56 - 0
dashboard/src/main/home/project-settings/Bars copy.tsx

@@ -0,0 +1,56 @@
+import React, { useState } from "react";
+import {
+  Bar,
+  BarChart,
+  CartesianGrid,
+  Legend,
+  Rectangle,
+  ResponsiveContainer,
+  Tooltip,
+  XAxis,
+  YAxis,
+} from "recharts";
+import styled from "styled-components";
+
+import Text from "components/porter/Text";
+
+type Props = {
+  data: any;
+  yKey: string;
+  xKey: string;
+  fill?: string;
+  title?: string;
+};
+
+const Bars: React.FC<Props> = ({ data, yKey, xKey, fill, title }) => {
+  return (
+    <ResponsiveContainer width="100%" height="100%">
+      <BarChart
+        width={500}
+        height={300}
+        data={data}
+        margin={{
+          top: 5,
+          right: 0,
+          left: -20,
+          bottom: 5,
+        }}
+      >
+        <CartesianGrid vertical={false} stroke="#ffffff22" />
+        <XAxis dataKey={xKey} tick={{ fontSize: 13 }} />
+        <YAxis tick={{ fontSize: 13 }} />
+        <Tooltip wrapperStyle={{ background: "red" }} />
+        <Bar dataKey={yKey} fill={fill || "#6A7FC4"} />
+      </BarChart>
+      <Center>
+        <Text color="helper">{title}</Text>
+      </Center>
+    </ResponsiveContainer>
+  );
+};
+
+export default Bars;
+
+const Center = styled.div`
+  text-align: center;
+`;

+ 78 - 0
dashboard/src/main/home/project-settings/Bars.tsx

@@ -0,0 +1,78 @@
+import React from "react";
+import {
+  Bar,
+  BarChart,
+  CartesianGrid,
+  ResponsiveContainer,
+  Tooltip,
+  XAxis,
+  YAxis,
+  type TooltipProps,
+} from "recharts";
+import styled from "styled-components";
+
+import Text from "components/porter/Text";
+
+type Props = {
+  data: Array<Record<string, unknown>>;
+  yKey: string;
+  xKey: string;
+  fill?: string;
+  title?: string;
+};
+
+const CustomTooltip = ({ active, payload }: TooltipProps<string, string>) => {
+  if (active && payload?.length) {
+    return (
+      <div
+        className="custom-tooltip"
+        style={{
+          backgroundColor: "#42444933",
+          backdropFilter: "saturate(150%) blur(8px)",
+          border: "1px solid #494b4f",
+          fontSize: "13px",
+          padding: "10px",
+          borderRadius: "5px",
+          color: "white",
+        }}
+      >
+        <p className="intro">{`Value: ${payload[0].value}`}</p>
+      </div>
+    );
+  }
+
+  return null;
+};
+
+const Bars: React.FC<Props> = ({ data, yKey, xKey, fill, title }) => {
+  return (
+    <ResponsiveContainer width="100%" height="100%">
+      <BarChart
+        width={500}
+        height={300}
+        data={data}
+        margin={{
+          top: 5,
+          right: 0,
+          left: -20,
+          bottom: 5,
+        }}
+      >
+        <CartesianGrid vertical={false} stroke="#ffffff22" />
+        <XAxis dataKey={xKey} tick={{ fontSize: 13 }} />
+        <YAxis tick={{ fontSize: 13 }} />
+        <Tooltip content={<CustomTooltip />} cursor={{ fill: "#ffffff11" }} />
+        <Bar dataKey={yKey} fill={fill || "#6A7FC4"} />
+      </BarChart>
+      <Center>
+        <Text color="helper">{title}</Text>
+      </Center>
+    </ResponsiveContainer>
+  );
+};
+
+export default Bars;
+
+const Center = styled.div`
+  text-align: center;
+`;

+ 82 - 21
dashboard/src/main/home/project-settings/BillingPage.tsx

@@ -1,5 +1,4 @@
-import React, { useContext, useState } from "react";
-import ParentSize from "@visx/responsive/lib/components/ParentSize";
+import React, { useContext, useMemo, useState } from "react";
 import styled from "styled-components";
 import styled from "styled-components";
 
 
 import Loading from "components/Loading";
 import Loading from "components/Loading";
@@ -13,7 +12,7 @@ import Text from "components/porter/Text";
 import {
 import {
   checkIfProjectHasPayment,
   checkIfProjectHasPayment,
   useCustomerPlan,
   useCustomerPlan,
-  useCustomeUsageDashboard,
+  useCustomerUsage,
   usePaymentMethods,
   usePaymentMethods,
   usePorterCredits,
   usePorterCredits,
   useSetDefaultPaymentMethod,
   useSetDefaultPaymentMethod,
@@ -25,6 +24,7 @@ import gift from "assets/gift.svg";
 import trashIcon from "assets/trash.png";
 import trashIcon from "assets/trash.png";
 
 
 import BillingModal from "../modals/BillingModal";
 import BillingModal from "../modals/BillingModal";
+import Bars from "./Bars";
 
 
 function BillingPage(): JSX.Element {
 function BillingPage(): JSX.Element {
   const { setCurrentOverlay } = useContext(Context);
   const { setCurrentOverlay } = useContext(Context);
@@ -44,7 +44,38 @@ function BillingPage(): JSX.Element {
 
 
   const { refetchPaymentEnabled } = checkIfProjectHasPayment();
   const { refetchPaymentEnabled } = checkIfProjectHasPayment();
 
 
-  const { url: usageDashboard } = useCustomeUsageDashboard("usage");
+  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 => {
   const formatCredits = (credits: number): string => {
     return (credits / 100).toFixed(2);
     return (credits / 100).toFixed(2);
@@ -109,10 +140,10 @@ function BillingPage(): JSX.Element {
       {paymentMethodList.map((paymentMethod, idx) => {
       {paymentMethodList.map((paymentMethod, idx) => {
         return (
         return (
           <div key={idx}>
           <div key={idx}>
-            <Fieldset>
+            <Fieldset row>
               <Container row spaced>
               <Container row spaced>
                 <Container row>
                 <Container row>
-                  <Icon src={cardIcon} height={"14px"} />
+                  <Icon opacity={0.5} src={cardIcon} height={"14px"} />
                   <Spacer inline x={1} />
                   <Spacer inline x={1} />
                   <Text color="helper">
                   <Text color="helper">
                     **** **** **** {paymentMethod.last4}
                     **** **** **** {paymentMethod.last4}
@@ -217,7 +248,7 @@ function BillingPage(): JSX.Element {
               <div>
               <div>
                 <Text>Active Plan</Text>
                 <Text>Active Plan</Text>
                 <Spacer y={0.5} />
                 <Spacer y={0.5} />
-                <Fieldset>
+                <Fieldset row>
                   <Container row spaced>
                   <Container row spaced>
                     <Container row>
                     <Container row>
                       <Text color="helper">{plan.plan_name}</Text>
                       <Text color="helper">{plan.plan_name}</Text>
@@ -241,20 +272,39 @@ function BillingPage(): JSX.Element {
                 <Text color="helper">
                 <Text color="helper">
                   View the current usage of this billing period.
                   View the current usage of this billing period.
                 </Text>
                 </Text>
-                <Spacer y={1} />{" "}
-                <Container row style={{ width: "100%", height: "80vh" }}>
-                  <ParentSize>
-                    {({ width, height }) => (
-                      <iframe
-                        width={width}
-                        height={height}
-                        src={usageDashboard}
-                        scrolling="no"
-                        frameBorder={0}
-                      ></iframe>
-                    )}
-                  </ParentSize>
-                </Container>
+                <Spacer y={1} />
+                {usage?.length &&
+                usage.length > 0 &&
+                usage[0].usage_metrics.length > 0 ? (
+                  <Flex>
+                    <BarWrapper>
+                      <Bars
+                        title="GiB Hours"
+                        fill="#8784D2"
+                        yKey="gib_hours"
+                        xKey="starting_on"
+                        data={processedData}
+                      />
+                    </BarWrapper>
+                    <Spacer x={1} inline />
+                    <BarWrapper>
+                      <Bars
+                        title="CPU Hours"
+                        fill="#5886E0"
+                        yKey="cpu_hours"
+                        xKey="starting_on"
+                        data={processedData}
+                      />
+                    </BarWrapper>
+                  </Flex>
+                ) : (
+                  <Fieldset>
+                    <Text color="helper">
+                      No usage data available for this billing period.
+                    </Text>
+                  </Fieldset>
+                )}
+                <Spacer y={2} />
               </div>
               </div>
             ) : (
             ) : (
               <Text>This project does not have an active billing plan.</Text>
               <Text>This project does not have an active billing plan.</Text>
@@ -270,6 +320,17 @@ function BillingPage(): JSX.Element {
 
 
 export default BillingPage;
 export default BillingPage;
 
 
+const Flex = styled.div`
+  display: flex;
+  flex-wrap: wrap;
+`;
+
+const BarWrapper = styled.div`
+  flex: 1;
+  height: 300px;
+  min-width: 450px;
+`;
+
 const I = styled.i`
 const I = styled.i`
   font-size: 18px;
   font-size: 18px;
   margin-right: 10px;
   margin-right: 10px;

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

@@ -3464,6 +3464,18 @@ const getPublishableKey = baseApi<
   ({ project_id }) => `/api/projects/${project_id}/billing/publishable_key`
   ({ project_id }) => `/api/projects/${project_id}/billing/publishable_key`
 );
 );
 
 
+const getCustomerUsage = baseApi<
+  {
+    window_size: string;
+    starting_on?: string;
+    ending_before?: string;
+    current_period?: boolean;
+  },
+  {
+    project_id?: number;
+  }
+>("POST", ({ project_id }) => `/api/projects/${project_id}/billing/usage`);
+
 const getUsageDashboard = baseApi<
 const getUsageDashboard = baseApi<
   {
   {
     dashboard: string;
     dashboard: string;
@@ -3909,6 +3921,7 @@ export default {
   getPublishableKey,
   getPublishableKey,
   getPorterCredits,
   getPorterCredits,
   getCustomerPlan,
   getCustomerPlan,
+  getCustomerUsage,
   getUsageDashboard,
   getUsageDashboard,
   listPaymentMethod,
   listPaymentMethod,
   addPaymentMethod,
   addPaymentMethod,

+ 87 - 4
internal/billing/metronome.go

@@ -24,6 +24,7 @@ const (
 // MetronomeClient is the client used to call the Metronome API
 // MetronomeClient is the client used to call the Metronome API
 type MetronomeClient struct {
 type MetronomeClient struct {
 	ApiKey               string
 	ApiKey               string
+	billableMetrics      []types.BillableMetric
 	PorterCloudPlanID    uuid.UUID
 	PorterCloudPlanID    uuid.UUID
 	PorterStandardPlanID uuid.UUID
 	PorterStandardPlanID uuid.UUID
 }
 }
@@ -257,8 +258,68 @@ func (m MetronomeClient) GetCustomerDashboard(ctx context.Context, customerID uu
 	return result.Data["url"], nil
 	return result.Data["url"], nil
 }
 }
 
 
+// ListCustomerUsage will return the aggregated usage for a customer
+func (m MetronomeClient) ListCustomerUsage(ctx context.Context, customerID uuid.UUID, startingOn string, endingBefore string, windowsSize string, currentPeriod bool) (usage []types.Usage, err error) {
+	ctx, span := telemetry.NewSpan(ctx, "list-customer-usage")
+	defer span.End()
+
+	if customerID == uuid.Nil {
+		return usage, telemetry.Error(ctx, span, err, "customer id empty")
+	}
+
+	if len(m.billableMetrics) == 0 {
+		billableMetrics, err := m.listBillableMetricIDs(ctx, customerID)
+		if err != nil {
+			return nil, telemetry.Error(ctx, span, err, "failed to list billable metrics")
+		}
+
+		telemetry.WithAttributes(span,
+			telemetry.AttributeKV{Key: "billable-metric-count", Value: len(billableMetrics)},
+		)
+
+		// Cache billable metric ids for future calls
+		m.billableMetrics = append(m.billableMetrics, billableMetrics...)
+	}
+
+	path := "usage/groups"
+
+	baseReq := types.ListCustomerUsageRequest{
+		CustomerID:    customerID,
+		WindowSize:    windowsSize,
+		StartingOn:    startingOn,
+		EndingBefore:  endingBefore,
+		CurrentPeriod: currentPeriod,
+	}
+
+	for _, billableMetric := range m.billableMetrics {
+		telemetry.WithAttributes(span,
+			telemetry.AttributeKV{Key: "billable-metric-id", Value: billableMetric.ID},
+		)
+
+		var result struct {
+			Data []types.CustomerUsageMetric `json:"data"`
+		}
+
+		baseReq.BillableMetricID = billableMetric.ID
+		_, err = m.do(http.MethodPost, path, baseReq, &result)
+		if err != nil {
+			return usage, telemetry.Error(ctx, span, err, "failed to get customer usage")
+		}
+
+		usage = append(usage, types.Usage{
+			MetricName:   billableMetric.Name,
+			UsageMetrics: result.Data,
+		})
+	}
+
+	return usage, nil
+}
+
 // IngestEvents sends a list of billing events to Metronome's ingest endpoint
 // IngestEvents sends a list of billing events to Metronome's ingest endpoint
 func (m MetronomeClient) IngestEvents(ctx context.Context, events []types.BillingEvent) (err error) {
 func (m MetronomeClient) IngestEvents(ctx context.Context, events []types.BillingEvent) (err error) {
+	ctx, span := telemetry.NewSpan(ctx, "ingets-billing-events")
+	defer span.End()
+
 	if len(events) == 0 {
 	if len(events) == 0 {
 		return nil
 		return nil
 	}
 	}
@@ -270,16 +331,16 @@ func (m MetronomeClient) IngestEvents(ctx context.Context, events []types.Billin
 		statusCode, err := m.do(http.MethodPost, path, events, nil)
 		statusCode, err := m.do(http.MethodPost, path, events, nil)
 		// Check errors that are not from error http codes
 		// Check errors that are not from error http codes
 		if statusCode == 0 && err != nil {
 		if statusCode == 0 && err != nil {
-			return err
+			return telemetry.Error(ctx, span, err, "failed to ingest billing events")
 		}
 		}
 
 
 		if statusCode == http.StatusForbidden || statusCode == http.StatusUnauthorized {
 		if statusCode == http.StatusForbidden || statusCode == http.StatusUnauthorized {
-			return fmt.Errorf("unauthorized")
+			return telemetry.Error(ctx, span, err, "unauthorized")
 		}
 		}
 
 
 		// 400 responses should not be retried
 		// 400 responses should not be retried
 		if statusCode == http.StatusBadRequest {
 		if statusCode == http.StatusBadRequest {
-			return fmt.Errorf("malformed billing events")
+			return telemetry.Error(ctx, span, err, "malformed billing events")
 		}
 		}
 
 
 		// Any other status code can be safely retried
 		// Any other status code can be safely retried
@@ -289,7 +350,29 @@ func (m MetronomeClient) IngestEvents(ctx context.Context, events []types.Billin
 		currentAttempts++
 		currentAttempts++
 	}
 	}
 
 
-	return fmt.Errorf("max number of retry attempts reached with no success")
+	return telemetry.Error(ctx, span, err, "max number of retry attempts reached with no success")
+}
+
+func (m MetronomeClient) listBillableMetricIDs(ctx context.Context, customerID uuid.UUID) (billableMetrics []types.BillableMetric, err error) {
+	ctx, span := telemetry.NewSpan(ctx, "list-billable-metrics")
+	defer span.End()
+
+	if customerID == uuid.Nil {
+		return billableMetrics, telemetry.Error(ctx, span, err, "customer id empty")
+	}
+
+	path := fmt.Sprintf("/customers/%s/billable-metrics", customerID)
+
+	var result struct {
+		Data []types.BillableMetric `json:"data"`
+	}
+
+	_, err = m.do(http.MethodGet, path, nil, &result)
+	if err != nil {
+		return billableMetrics, telemetry.Error(ctx, span, err, "failed to retrieve billable metrics from metronome")
+	}
+
+	return result.Data, nil
 }
 }
 
 
 func (m MetronomeClient) do(method string, path string, body interface{}, data interface{}) (statusCode int, err error) {
 func (m MetronomeClient) do(method string, path string, body interface{}, data interface{}) (statusCode int, err error) {