فهرست منبع

Merge branch 'develop' into optional-scrape-endpoint

Cliff Colvin 2 سال پیش
والد
کامیت
986fc5f9fb
40فایلهای تغییر یافته به همراه2740 افزوده شده و 1226 حذف شده
  1. 4 1
      .gitignore
  2. 1 1
      NOTICE
  3. 13 10
      README.md
  4. 10 10
      go.mod
  5. 21 20
      go.sum
  6. 22 6
      pkg/cloud/azure/provider.go
  7. 6 2
      pkg/cloud/gcp/bigqueryquerier.go
  8. 7 3
      pkg/costmodel/cluster.go
  9. 27 37
      pkg/costmodel/costmodel.go
  10. 11 2
      pkg/kubecost/asset_test.go
  11. 7 5
      ui/README.md
  12. 3 1
      ui/default.nginx.conf
  13. 265 488
      ui/package-lock.json
  14. 10 9
      ui/package.json
  15. 203 175
      ui/src/Reports.js
  16. 4 17
      ui/src/app.js
  17. 217 0
      ui/src/cloudCost/cloudCost.js
  18. 14 0
      ui/src/cloudCost/cloudCostChart/index.js
  19. 275 0
      ui/src/cloudCost/cloudCostChart/rangeChart.js
  20. 178 0
      ui/src/cloudCost/cloudCostDetails.js
  21. 48 0
      ui/src/cloudCost/cloudCostRow.js
  22. 91 0
      ui/src/cloudCost/controls/cloudCostEditControls.js
  23. 49 0
      ui/src/cloudCost/tokens.js
  24. 305 0
      ui/src/cloudCostReports.js
  25. 0 200
      ui/src/components/AllocationReport.js
  26. 37 25
      ui/src/components/Header.js
  27. 78 0
      ui/src/components/Nav/NavItem.js
  28. 70 0
      ui/src/components/Nav/SidebarNav.js
  29. 3 0
      ui/src/components/Nav/index.js
  30. 32 19
      ui/src/components/Page.js
  31. 19 18
      ui/src/components/Subtitle.js
  32. 241 0
      ui/src/components/allocationReport.js
  33. 14 11
      ui/src/index.html
  34. BIN
      ui/src/opencost-ui.png
  35. 25 0
      ui/src/route.js
  36. 11 9
      ui/src/services/allocation.js
  37. 43 0
      ui/src/services/cloudCostDayTotals.js
  38. 54 0
      ui/src/services/cloudCostTop.js
  39. BIN
      ui/src/thumbnail.png
  40. 322 157
      ui/src/util.js

+ 4 - 1
.gitignore

@@ -12,4 +12,7 @@ cmd/costmodel/costmodel-arm64
 pkg/cloud/azureorphan_test.go
 
 # VS Code
-.vscode
+.vscode
+
+#Apple
+*.DS_Store

+ 1 - 1
NOTICE

@@ -1,5 +1,5 @@
 OpenCost
-Copyright 2022 Cloud Native Computing Foundation
+Copyright 2022 - 2023 Cloud Native Computing Foundation
 
 This product includes software developed at
 The Cloud Native Computing Foundation (http://www.cncf.io).

+ 13 - 10
README.md

@@ -1,39 +1,42 @@
 <img src="./opencost-header.png"/>
 
-# OpenCost — your favorite open source cost monitoring tool for Kubernetes
+# OpenCost — your favorite open source cost monitoring tool for Kubernetes and cloud spend
 
-OpenCost models give teams visibility into current and historical Kubernetes spend and resource allocation. These models provide cost transparency in Kubernetes environments that support multiple applications, teams, departments, etc.
+OpenCost give teams visibility into current and historical Kubernetes and cloud spend and resource allocation.
+These models provide cost transparency in Kubernetes environments that support multiple applications, teams, departments, etc.
+It also provides visibility into the cloud costs across multiple providers.
 
 OpenCost was originally developed and open sourced by [Kubecost](https://kubecost.com). This project combines a [specification](/spec/) as well as a Golang implementation of these detailed requirements.
 
-![OpenCost allocation UI](./ui/src/opencost-ui.png)
+[![OpenCost UI Walkthrough](./ui/src/thumbnail.png)](https://youtu.be/lCP4Ci9Kcdg)
+*OpenCost UI Walkthrough*
 
 To see the full functionality of OpenCost you can view [OpenCost features](https://opencost.io). Here is a summary of features enabled:
 
 - Real-time cost allocation by Kubernetes cluster, node, namespace, controller kind, controller, service, or pod
-- Dynamic on-demand asset pricing enabled by integrations with AWS, Azure, and GCP billing APIs
+- Multi-cloud cost monitoring for all cloud services on AWS, Azure, GCP
+- Dynamic on-demand k8s asset pricing enabled by integrations with AWS, Azure, and GCP billing APIs
 - Supports on-prem k8s clusters with custom CSV pricing
-- Allocation for in-cluster resources like CPU, GPU, memory, and persistent volumes.
+- Allocation for in-cluster K8s resources like CPU, GPU, memory, and persistent volumes
 - Easily export pricing data to Prometheus with /metrics endpoint ([learn more](https://www.opencost.io/docs/installation/prometheus))
 - Free and open source distribution ([Apache2 license](LICENSE))
 
 ## Getting Started
 
-You can deploy OpenCost on any Kubernetes 1.8+ cluster in a matter of minutes, if not seconds!
+You can deploy OpenCost on any Kubernetes 1.20+ cluster in a matter of minutes, if not seconds!
 
-Visit the full documentation for [recommended install options](https://www.opencost.io/docs/installation/install).
+Visit the full documentation for [recommended installation options](https://www.opencost.io/docs/installation/install).
 
 ## Usage
 
 - [Cost APIs](https://www.opencost.io/docs/integrations/api)
 - [CLI / kubectl cost](https://www.opencost.io/docs/integrations/kubectl-cost)
 - [Prometheus Metrics](https://www.opencost.io/docs/integrations/prometheus)
-- Reference [User Interface](https://github.com/opencost/opencost/tree/develop/ui)
+- [User Interface](https://www.opencost.io/docs/installation/ui)
 
 ## Contributing
 
-We :heart: pull requests! See [`CONTRIBUTING.md`](CONTRIBUTING.md) for information on building the project from source
-and contributing changes.
+We :heart: pull requests! See [`CONTRIBUTING.md`](CONTRIBUTING.md) for information on building the project from source and contributing changes.
 
 ## Community
 

+ 10 - 10
go.mod

@@ -3,9 +3,9 @@ module github.com/opencost/opencost
 replace github.com/golang/lint => golang.org/x/lint v0.0.0-20180702182130-06c8688daad7
 
 require (
-	cloud.google.com/go/bigquery v1.48.0
+	cloud.google.com/go/bigquery v1.50.0
 	cloud.google.com/go/compute/metadata v0.2.3
-	cloud.google.com/go/storage v1.28.1
+	cloud.google.com/go/storage v1.29.0
 	github.com/Azure/azure-pipeline-go v0.2.3
 	github.com/Azure/azure-sdk-for-go v65.0.0+incompatible
 	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.5.0
@@ -53,11 +53,11 @@ require (
 	go.etcd.io/bbolt v1.3.5
 	go.opentelemetry.io/otel v1.19.0
 	golang.org/x/exp v0.0.0-20221031165847-c99f073a8326
-	golang.org/x/oauth2 v0.6.0
+	golang.org/x/oauth2 v0.7.0
 	golang.org/x/sync v0.1.0
 	golang.org/x/text v0.13.0
 	google.golang.org/api v0.114.0
-	google.golang.org/protobuf v1.29.1
+	google.golang.org/protobuf v1.30.0
 	gopkg.in/yaml.v2 v2.4.0
 	k8s.io/api v0.25.3
 	k8s.io/apimachinery v0.25.3
@@ -67,8 +67,8 @@ require (
 
 require (
 	cloud.google.com/go v0.110.0 // indirect
-	cloud.google.com/go/compute v1.18.0 // indirect
-	cloud.google.com/go/iam v0.12.0 // indirect
+	cloud.google.com/go/compute v1.19.1 // indirect
+	cloud.google.com/go/iam v0.13.0 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0 // indirect
 	github.com/Azure/go-autorest v14.2.0+incompatible // indirect
 	github.com/Azure/go-autorest/autorest/azure/cli v0.4.5 // indirect
@@ -81,7 +81,7 @@ require (
 	github.com/PuerkitoBio/purell v1.1.1 // indirect
 	github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
 	github.com/andybalholm/brotli v1.0.4 // indirect
-	github.com/apache/arrow/go/v10 v10.0.1 // indirect
+	github.com/apache/arrow/go/v11 v11.0.0 // indirect
 	github.com/apache/thrift v0.16.0 // indirect
 	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect
 	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.10.0 // indirect
@@ -110,7 +110,7 @@ require (
 	github.com/gogo/protobuf v1.3.2 // indirect
 	github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
-	github.com/golang/protobuf v1.5.2 // indirect
+	github.com/golang/protobuf v1.5.3 // indirect
 	github.com/golang/snappy v0.0.4 // indirect
 	github.com/google/flatbuffers v2.0.8+incompatible // indirect
 	github.com/google/gnostic v0.5.7-v3refs // indirect
@@ -169,8 +169,8 @@ require (
 	golang.org/x/tools v0.6.0 // indirect
 	golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
-	google.golang.org/genproto v0.0.0-20230320184635-7606e756e683 // indirect
-	google.golang.org/grpc v1.53.0 // indirect
+	google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect
+	google.golang.org/grpc v1.56.3 // indirect
 	gopkg.in/inf.v0 v0.9.1 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect

+ 21 - 20
go.sum

@@ -26,18 +26,18 @@ cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvf
 cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
 cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
 cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
-cloud.google.com/go/bigquery v1.48.0 h1:u+fhS1jJOkPO9vdM84M8HO5VznTfVUicBeoXNKD26ho=
-cloud.google.com/go/bigquery v1.48.0/go.mod h1:QAwSz+ipNgfL5jxiaK7weyOhzdoAy1zFm0Nf1fysJac=
-cloud.google.com/go/compute v1.18.0 h1:FEigFqoDbys2cvFkZ9Fjq4gnHBP55anJ0yQyau2f9oY=
-cloud.google.com/go/compute v1.18.0/go.mod h1:1X7yHxec2Ga+Ss6jPyjxRxpu2uu7PLgsOVXvgU0yacs=
+cloud.google.com/go/bigquery v1.50.0 h1:RscMV6LbnAmhAzD893Lv9nXXy2WCaJmbxYPWDLbGqNQ=
+cloud.google.com/go/bigquery v1.50.0/go.mod h1:YrleYEh2pSEbgTBZYMJ5SuSr0ML3ypjRB1zgf7pvQLU=
+cloud.google.com/go/compute v1.19.1 h1:am86mquDUgjGNWxiGn+5PGLbmgiWXlE/yNWpIpNvuXY=
+cloud.google.com/go/compute v1.19.1/go.mod h1:6ylj3a05WF8leseCdIf77NK0g1ey+nj5IKd5/kvShxE=
 cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
 cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
-cloud.google.com/go/datacatalog v1.12.0 h1:3uaYULZRLByPdbuUvacGeqneudztEM4xqKQsBcxbDnY=
+cloud.google.com/go/datacatalog v1.13.0 h1:4H5IJiyUE0X6ShQBqgFFZvGGcrwGVndTwUSLP4c52gw=
 cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
 cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
 cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
-cloud.google.com/go/iam v0.12.0 h1:DRtTY29b75ciH6Ov1PHb4/iat2CLCvrOm40Q0a6DFpE=
-cloud.google.com/go/iam v0.12.0/go.mod h1:knyHGviacl11zrtZUoDuYpDgLjvr28sLQaG0YB2GYAY=
+cloud.google.com/go/iam v0.13.0 h1:+CmB+K0J/33d0zSQ9SlFWUeCCEn5XJA0ZMZ3pHE9u8k=
+cloud.google.com/go/iam v0.13.0/go.mod h1:ljOg+rcNfzZ5d6f1nAUJ8ZIxOaZUVoS14bKCtaLZ/D0=
 cloud.google.com/go/longrunning v0.4.1 h1:v+yFJOfKC3yZdY6ZUI933pIYdhyhV8S3NpWrXWmg7jM=
 cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
 cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
@@ -48,8 +48,8 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo
 cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
 cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
 cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
-cloud.google.com/go/storage v1.28.1 h1:F5QDG5ChchaAVQhINh24U99OWHURqrW8OmQcGKXcbgI=
-cloud.google.com/go/storage v1.28.1/go.mod h1:Qnisd4CqDdo6BGs2AD5LLnEsmSQ80wQ5ogcBBKhU86Y=
+cloud.google.com/go/storage v1.29.0 h1:6weCgzRvMg7lzuUurI4697AqIRPU1SvzHhynwpW31jI=
+cloud.google.com/go/storage v1.29.0/go.mod h1:4puEjyTKnku6gfKoTfNOU/W+a9JyuVNxjpS5GBrB8h4=
 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
 github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
 github.com/Azure/azure-pipeline-go v0.2.3 h1:7U9HBg1JFK3jHl5qmo4CTZKFTVgMwdFHMVtCdfBE21U=
@@ -117,8 +117,8 @@ github.com/aliyun/alibaba-cloud-sdk-go v1.62.3/go.mod h1:Api2AkmMgGaSUAhmk76oaFO
 github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
 github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
 github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
-github.com/apache/arrow/go/v10 v10.0.1 h1:n9dERvixoC/1JjDmBcs9FPaEryoANa2sCgVFo6ez9cI=
-github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0=
+github.com/apache/arrow/go/v11 v11.0.0 h1:hqauxvFQxww+0mEU/2XHG6LT7eZternCZq+A5Yly2uM=
+github.com/apache/arrow/go/v11 v11.0.0/go.mod h1:Eg5OsL5H+e299f7u5ssuXsuHQVEGC4xei5aX110hRiI=
 github.com/apache/thrift v0.16.0 h1:qEy6UW60iVOlUy+b9ZR0d5WzUWYGOo4HfopoyBaNmoY=
 github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU=
 github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
@@ -322,8 +322,9 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw
 github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
 github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
 github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
-github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
 github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
+github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
 github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
 github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
 github.com/gomodule/redigo v1.7.1-0.20190724094224-574c33c3df38/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
@@ -852,8 +853,8 @@ golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ
 golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
-golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw=
-golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw=
+golang.org/x/oauth2 v0.7.0 h1:qe6s0zUXlPX80/dITx3440hWZ7GwMwgDDyrSGTPJG/g=
+golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -1101,8 +1102,8 @@ google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6D
 google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
 google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
-google.golang.org/genproto v0.0.0-20230320184635-7606e756e683 h1:khxVcsk/FhnzxMKOyD+TDGwjbEOpcPuIpmafPGFmhMA=
-google.golang.org/genproto v0.0.0-20230320184635-7606e756e683/go.mod h1:NWraEVixdDnqcqQ30jipen1STv2r/n24Wb7twVTGR4s=
+google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 h1:KpwkzHKEF7B9Zxg18WzOa7djJ+Ha5DzthMyZYQfEn2A=
+google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
 google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@@ -1123,8 +1124,8 @@ google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAG
 google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
 google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
 google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
-google.golang.org/grpc v1.53.0 h1:LAv2ds7cmFV/XTS3XG1NneeENYrXGmorPxsBbptIjNc=
-google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw=
+google.golang.org/grpc v1.56.3 h1:8I4C0Yq1EjstUzUJzpcRVbuYA2mODtEmpWiQoN/b2nc=
+google.golang.org/grpc v1.56.3/go.mod h1:I9bI3vqKfayGqPUAwGdOSu7kt6oIJLixfffKrpXqQ9s=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -1137,8 +1138,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
 google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.29.1 h1:7QBf+IK2gx70Ap/hDsOmam3GE0v9HicjfEdAxE62UoM=
-google.golang.org/protobuf v1.29.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
+google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
 gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

+ 22 - 6
pkg/cloud/azure/provider.go

@@ -1097,7 +1097,9 @@ func (az *Azure) NodePricing(key models.Key) (*models.Node, models.PricingMetada
 	config, _ := az.GetConfig()
 
 	// Spot Node
-	if slv, ok := azKey.Labels[config.SpotLabel]; ok && slv == config.SpotLabelValue && config.SpotLabel != "" && config.SpotLabelValue != "" {
+	slv, ok := azKey.Labels[config.SpotLabel]
+	isSpot := ok && slv == config.SpotLabelValue && config.SpotLabel != "" && config.SpotLabelValue != ""
+	if isSpot {
 		features := strings.Split(azKey.Features(), ",")
 		region := features[0]
 		instance := features[1]
@@ -1147,13 +1149,27 @@ func (az *Azure) NodePricing(key models.Key) (*models.Node, models.PricingMetada
 		return nil, meta, fmt.Errorf("No default pricing data available")
 	}
 
+	var vcpuCost string
+	var ramCost string
+	var gpuCost string
+
+	if isSpot {
+		vcpuCost = c.SpotCPU
+		ramCost = c.SpotRAM
+		gpuCost = c.SpotGPU
+	} else {
+		vcpuCost = c.CPU
+		ramCost = c.RAM
+		gpuCost = c.GPU
+	}
+
 	// GPU Node
 	if azKey.isValidGPUNode() {
 		return &models.Node{
-			VCPUCost:         c.CPU,
-			RAMCost:          c.RAM,
+			VCPUCost:         vcpuCost,
+			RAMCost:          ramCost,
 			UsesBaseCPUPrice: true,
-			GPUCost:          c.GPU,
+			GPUCost:          gpuCost,
 			GPU:              azKey.GetGPUCount(),
 		}, meta, nil
 	}
@@ -1170,8 +1186,8 @@ func (az *Azure) NodePricing(key models.Key) (*models.Node, models.PricingMetada
 
 	// Regular Node
 	return &models.Node{
-		VCPUCost:         c.CPU,
-		RAMCost:          c.RAM,
+		VCPUCost:         vcpuCost,
+		RAMCost:          ramCost,
 		UsesBaseCPUPrice: true,
 	}, meta, nil
 }

+ 6 - 2
pkg/cloud/gcp/bigqueryquerier.go

@@ -2,6 +2,7 @@ package gcp
 
 import (
 	"context"
+	"fmt"
 
 	"cloud.google.com/go/bigquery"
 	"github.com/opencost/opencost/pkg/cloud"
@@ -49,9 +50,12 @@ func (bqq *BigQueryQuerier) Query(ctx context.Context, queryStr string) (*bigque
 	// If result is empty and connection status is not already successful update status to missing data
 	if iter == nil && bqq.ConnectionStatus != cloud.SuccessfulConnection {
 		bqq.ConnectionStatus = cloud.MissingData
-		return iter, nil
+	} else {
+		bqq.ConnectionStatus = cloud.SuccessfulConnection
 	}
 
-	bqq.ConnectionStatus = cloud.SuccessfulConnection
+	if err != nil {
+		return iter, fmt.Errorf("BigQueryQuerier: Query: error reading query results: %w", err)
+	}
 	return iter, nil
 }

+ 7 - 3
pkg/costmodel/cluster.go

@@ -848,10 +848,14 @@ func ClusterLoadBalancers(client prometheus.Client, start, end time.Time) (map[L
 
 			// interpolate any missing data
 			resultMins := lb.Minutes
-			scaleFactor := (resultMins + resolution.Minutes()) / resultMins
+			if resultMins > 0 {
+				scaleFactor := (resultMins + resolution.Minutes()) / resultMins
 
-			hrs := (lb.Minutes * scaleFactor) / 60.0
-			lb.Cost += lbPricePerHr * hrs
+				hrs := (lb.Minutes * scaleFactor) / 60.0
+				lb.Cost += lbPricePerHr * hrs
+			} else {
+				log.DedupedWarningf(20, "ClusterLoadBalancers: found zero minutes for key: %v", key)
+			}
 
 			if lb.Ip != "" && lb.Ip != providerID {
 				log.DedupedWarningf(5, "ClusterLoadBalancers: multiple IPs per load balancer not supported, using most recent IP")

+ 27 - 37
pkg/costmodel/costmodel.go

@@ -144,39 +144,25 @@ const (
 		label_replace(
 			label_replace(
 				avg(
-					count_over_time(kube_pod_container_resource_requests{resource="memory", unit="byte", container!="",container!="POD", node!="", %s}[%s] %s)
-					*
-					avg_over_time(kube_pod_container_resource_requests{resource="memory", unit="byte", container!="",container!="POD", node!="", %s}[%s] %s)
+					sum_over_time(kube_pod_container_resource_requests{resource="memory", unit="byte", container!="",container!="POD", node!="", %s}[%s] %s)
 				) by (namespace,container,pod,node,%s) , "container_name","$1","container","(.+)"
 			), "pod_name","$1","pod","(.+)"
 		)
 	) by (namespace,container_name,pod_name,node,%s)`
-	queryRAMUsageStr = `sort_desc(
-		avg(
-			label_replace(
-				label_replace(
-					label_replace(
-						count_over_time(container_memory_working_set_bytes{container!="", container!="POD", instance!="", %s}[%s] %s), "node", "$1", "instance", "(.+)"
-					), "container_name", "$1", "container", "(.+)"
-				), "pod_name", "$1", "pod", "(.+)"
-			)
-			*
+	queryRAMUsageStr = `avg(
+		label_replace(
 			label_replace(
 				label_replace(
-					label_replace(
-						avg_over_time(container_memory_working_set_bytes{container!="", container!="POD", instance!="", %s}[%s] %s), "node", "$1", "instance", "(.+)"
-					), "container_name", "$1", "container", "(.+)"
-				), "pod_name", "$1", "pod", "(.+)"
-			)
-		) by (namespace, container_name, pod_name, node, %s)
-	)`
+					sum_over_time(container_memory_working_set_bytes{container!="", container!="POD", instance!="", %s}[%s] %s), "node", "$1", "instance", "(.+)"
+				), "container_name", "$1", "container", "(.+)"
+			), "pod_name", "$1", "pod", "(.+)"
+		)
+	) by (namespace, container_name, pod_name, node, %s)`
 	queryCPURequestsStr = `avg(
 		label_replace(
 			label_replace(
 				avg(
-					count_over_time(kube_pod_container_resource_requests{resource="cpu", unit="core", container!="",container!="POD", node!="", %s}[%s] %s)
-					*
-					avg_over_time(kube_pod_container_resource_requests{resource="cpu", unit="core", container!="",container!="POD", node!="", %s}[%s] %s)
+					sum_over_time(kube_pod_container_resource_requests{resource="cpu", unit="core", container!="",container!="POD", node!="", %s}[%s] %s)
 				) by (namespace,container,pod,node,%s) , "container_name","$1","container","(.+)"
 			), "pod_name","$1","pod","(.+)"
 		)
@@ -196,9 +182,7 @@ const (
 		label_replace(
 			label_replace(
 				avg(
-					count_over_time(kube_pod_container_resource_requests{resource="nvidia_com_gpu", container!="",container!="POD", node!="", %s}[%s] %s)
-					*
-					avg_over_time(kube_pod_container_resource_requests{resource="nvidia_com_gpu", container!="",container!="POD", node!="", %s}[%s] %s)
+					sum_over_time(kube_pod_container_resource_requests{resource="nvidia_com_gpu", container!="",container!="POD", node!="", %s}[%s] %s)
 					* %f
 				) by (namespace,container,pod,node,%s) , "container_name","$1","container","(.+)"
 			), "pod_name","$1","pod","(.+)"
@@ -253,7 +237,7 @@ const (
 )
 
 func (cm *CostModel) ComputeCostData(cli prometheusClient.Client, cp costAnalyzerCloud.Provider, window string, offset string, filterNamespace string) (map[string]*CostData, error) {
-	queryRAMUsage := fmt.Sprintf(queryRAMUsageStr, env.GetPromClusterFilter(), window, offset, env.GetPromClusterFilter(), window, offset, env.GetPromClusterLabel())
+	queryRAMUsage := fmt.Sprintf(queryRAMUsageStr, env.GetPromClusterFilter(), window, offset, env.GetPromClusterLabel())
 	queryCPUUsage := fmt.Sprintf(queryCPUUsageStr, env.GetPromClusterFilter(), window, offset, env.GetPromClusterLabel())
 	queryNetZoneRequests := fmt.Sprintf(queryZoneNetworkUsage, env.GetPromClusterFilter(), window, "", env.GetPromClusterLabel())
 	queryNetRegionRequests := fmt.Sprintf(queryRegionNetworkUsage, env.GetPromClusterFilter(), window, "", env.GetPromClusterLabel())
@@ -333,6 +317,9 @@ func (cm *CostModel) ComputeCostData(cli prometheusClient.Client, cp costAnalyze
 	// Determine if there are vgpus configured and if so get the total allocatable number
 	// If there are no vgpus, the coefficient is set to 1.0
 	vgpuCount, err := getAllocatableVGPUs(cm.Cache)
+	if err != nil {
+		log.Warnf("getAllocatableVGCPUs error: %s", err.Error())
+	}
 	vgpuCoeff := 10.0
 	if vgpuCount > 0.0 {
 		vgpuCoeff = vgpuCount
@@ -1019,6 +1006,9 @@ func (cm *CostModel) GetNodeCost(cp costAnalyzerCloud.Provider) (map[string]*cos
 	nodes := make(map[string]*costAnalyzerCloud.Node)
 
 	vgpuCount, err := getAllocatableVGPUs(cm.Cache)
+	if err != nil {
+		return nil, err
+	}
 	vgpuCoeff := 10.0
 	if vgpuCount > 0.0 {
 		vgpuCoeff = vgpuCount
@@ -1161,14 +1151,14 @@ func (cm *CostModel) GetNodeCost(cp costAnalyzerCloud.Provider) (map[string]*cos
 
 			cpuToRAMRatio := defaultCPU / defaultRAM
 			if math.IsNaN(cpuToRAMRatio) {
-				log.Warnf("cpuToRAMRatio[defaultCPU: %f / defaultRAM: %f] is NaN. Setting to 0.", defaultCPU, defaultRAM)
-				cpuToRAMRatio = 0
+				log.Warnf("cpuToRAMRatio[defaultCPU: %f / defaultRAM: %f] is NaN. Setting to 10.", defaultCPU, defaultRAM)
+				cpuToRAMRatio = 10
 			}
 
 			gpuToRAMRatio := defaultGPU / defaultRAM
 			if math.IsNaN(gpuToRAMRatio) {
-				log.Warnf("gpuToRAMRatio is NaN. Setting to 0.")
-				gpuToRAMRatio = 0
+				log.Warnf("gpuToRAMRatio is NaN. Setting to 100.")
+				gpuToRAMRatio = 100
 			}
 
 			ramGB := ram / 1024 / 1024 / 1024
@@ -1244,8 +1234,8 @@ func (cm *CostModel) GetNodeCost(cp costAnalyzerCloud.Provider) (map[string]*cos
 
 			cpuToRAMRatio := defaultCPU / defaultRAM
 			if math.IsNaN(cpuToRAMRatio) {
-				log.Warnf("cpuToRAMRatio[defaultCPU: %f / defaultRAM: %f] is NaN. Setting to 0.", defaultCPU, defaultRAM)
-				cpuToRAMRatio = 0
+				log.Warnf("cpuToRAMRatio[defaultCPU: %f / defaultRAM: %f] is NaN. Setting to 10.", defaultCPU, defaultRAM)
+				cpuToRAMRatio = 10
 			}
 
 			ramGB := ram / 1024 / 1024 / 1024
@@ -1693,11 +1683,11 @@ func (cm *CostModel) costDataRange(cli prometheusClient.Client, cp costAnalyzerC
 
 	queryRAMAlloc := fmt.Sprintf(queryRAMAllocationByteHours, env.GetPromClusterFilter(), resStr, env.GetPromClusterLabel(), scrapeIntervalSeconds)
 	queryCPUAlloc := fmt.Sprintf(queryCPUAllocationVCPUHours, env.GetPromClusterFilter(), resStr, env.GetPromClusterLabel(), scrapeIntervalSeconds)
-	queryRAMRequests := fmt.Sprintf(queryRAMRequestsStr, env.GetPromClusterFilter(), resStr, "", env.GetPromClusterFilter(), resStr, "", env.GetPromClusterLabel(), env.GetPromClusterLabel())
-	queryRAMUsage := fmt.Sprintf(queryRAMUsageStr, env.GetPromClusterFilter(), resStr, "", env.GetPromClusterFilter(), resStr, "", env.GetPromClusterLabel())
-	queryCPURequests := fmt.Sprintf(queryCPURequestsStr, env.GetPromClusterFilter(), resStr, "", env.GetPromClusterFilter(), resStr, "", env.GetPromClusterLabel(), env.GetPromClusterLabel())
+	queryRAMRequests := fmt.Sprintf(queryRAMRequestsStr, env.GetPromClusterFilter(), resStr, "", env.GetPromClusterLabel(), env.GetPromClusterLabel())
+	queryRAMUsage := fmt.Sprintf(queryRAMUsageStr, env.GetPromClusterFilter(), resStr, "", env.GetPromClusterLabel())
+	queryCPURequests := fmt.Sprintf(queryCPURequestsStr, env.GetPromClusterFilter(), resStr, "", env.GetPromClusterLabel(), env.GetPromClusterLabel())
 	queryCPUUsage := fmt.Sprintf(queryCPUUsageStr, env.GetPromClusterFilter(), resStr, "", env.GetPromClusterLabel())
-	queryGPURequests := fmt.Sprintf(queryGPURequestsStr, env.GetPromClusterFilter(), resStr, "", env.GetPromClusterFilter(), resStr, "", resolution.Hours(), env.GetPromClusterLabel(), env.GetPromClusterLabel(), env.GetPromClusterLabel(), env.GetPromClusterFilter(), resStr, "", env.GetPromClusterLabel())
+	queryGPURequests := fmt.Sprintf(queryGPURequestsStr, env.GetPromClusterFilter(), resStr, "", resolution.Hours(), env.GetPromClusterLabel(), env.GetPromClusterLabel(), env.GetPromClusterLabel(), env.GetPromClusterFilter(), resStr, "", env.GetPromClusterLabel())
 	queryPVRequests := fmt.Sprintf(queryPVRequestsStr, env.GetPromClusterFilter(), env.GetPromClusterLabel(), env.GetPromClusterLabel(), env.GetPromClusterFilter(), env.GetPromClusterLabel(), env.GetPromClusterLabel())
 	queryPVCAllocation := fmt.Sprintf(queryPVCAllocationFmt, env.GetPromClusterFilter(), resStr, env.GetPromClusterLabel(), scrapeIntervalSeconds)
 	queryPVHourlyCost := fmt.Sprintf(queryPVHourlyCostFmt, env.GetPromClusterFilter(), resStr)

+ 11 - 2
pkg/kubecost/asset_test.go

@@ -959,10 +959,13 @@ func TestAssetSetRange_AccumulateToAssetSet(t *testing.T) {
 		GenerateMockAssetSet(startD2, day),
 	)
 	err = asr.AggregateBy(nil, nil)
-	as, err = asr.AccumulateToAssetSet()
 	if err != nil {
 		t.Fatalf("AssetSetRange.AggregateBy: unexpected error: %s", err)
 	}
+	as, err = asr.AccumulateToAssetSet()
+	if err != nil {
+		t.Fatalf("AssetSetRange.AccumulateToAssetSet: unexpected error: %s", err)
+	}
 	assertAssetSet(t, as, "1a", window, map[string]float64{
 		"__undefined__/__undefined__/__undefined__/Compute/cluster1/Node/Kubernetes/gcp-node1/node1":                   21.00,
 		"__undefined__/__undefined__/__undefined__/Compute/cluster1/Node/Kubernetes/gcp-node2/node2":                   16.50,
@@ -983,10 +986,13 @@ func TestAssetSetRange_AccumulateToAssetSet(t *testing.T) {
 		GenerateMockAssetSet(startD2, day),
 	)
 	err = asr.AggregateBy([]string{}, nil)
-	as, err = asr.AccumulateToAssetSet()
 	if err != nil {
 		t.Fatalf("AssetSetRange.AggregateBy: unexpected error: %s", err)
 	}
+	as, err = asr.AccumulateToAssetSet()
+	if err != nil {
+		t.Fatalf("AssetSetRange.AccumulateToAssetSet: unexpected error: %s", err)
+	}
 	assertAssetSet(t, as, "1b", window, map[string]float64{
 		"": 180.00,
 	}, nil)
@@ -1038,6 +1044,9 @@ func TestAssetSetRange_AccumulateToAssetSet(t *testing.T) {
 	)
 
 	err = asr.AggregateBy([]string{string(AssetTypeProp)}, nil)
+	if err != nil {
+		t.Fatalf("AssetSetRange.AggregateBy: unexpected error: %s", err)
+	}
 	as, err = asr.AccumulateToAssetSet()
 	if err != nil {
 		t.Fatalf("AssetSetRange.AggregateBy: unexpected error: %s", err)

+ 7 - 5
ui/README.md

@@ -26,8 +26,10 @@ kubectl port-forward --namespace opencost service/opencost 9090
 The UI can be run locally using the `npm run serve` command.
 
 ```sh
+$ npm install
+...
 $ npm run serve
-> kubecost-ui-open@0.0.1 serve
+> opencost-ui@0.1.0 serve
 > npx parcel serve src/index.html
 
 Server running at http://localhost:1234
@@ -39,7 +41,7 @@ And can have a custom URL backend prefix.
 ```sh
 BASE_URL=http://localhost:9090/test npm run serve
 
-> kubecost-ui-open@0.0.1 serve
+> opencost-ui@0.1.0 serve
 > npx parcel serve src/index.html
 
 Server running at http://localhost:1234
@@ -51,13 +53,13 @@ In addition, similar behavior can be replicated with the docker container:
 ```sh
 $ docker run -e BASE_URL_OVERRIDE=test -p 9091:9090 -d opencost-ui:latest
 $ curl localhost:9091
-<html gibberish> 
+<html gibberish>
 ```
 
 ## Overriding the Base API URL
 
-For some use cases such as the case of [Opencost deployed behind an ingress controller](https://github.com/opencost/opencost/issues/1677), it is useful to override the `BASE_URL` variable responsible for requests sent from the UI to the API.  This means that instead of sending requests to `<domain>/model/allocation/compute/etc`, requests can be sent to `<domain>/{BASE_URL_OVERRIDE}/allocation/compute/etc`.  To do this, supply the environment variable `BASE_URL_OVERRIDE` to the docker image.
+For some use cases such as the case of [OpenCost deployed behind an ingress controller](https://github.com/opencost/opencost/issues/1677), it is useful to override the `BASE_URL` variable responsible for requests sent from the UI to the API.  This means that instead of sending requests to `<domain>/model/allocation/compute/etc`, requests can be sent to `<domain>/{BASE_URL_OVERRIDE}/allocation/compute/etc`.  To do this, supply the environment variable `BASE_URL_OVERRIDE` to the docker image.
 
 ```sh
 $ docker run -p 9091:9090 -e BASE_URL_OVERRIDE=anything -d opencost-ui:latest
-```
+```

+ 3 - 1
ui/default.nginx.conf

@@ -52,7 +52,9 @@ server {
 
     add_header Cache-Control "max-age=300";
     location / {
-        try_files $uri $uri/ /index.html;
+        root /var/www;
+        index index.html index.htm;
+        try_files $uri /index.html;
     }
 
     add_header ETag "1.96.0";

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 265 - 488
ui/package-lock.json


+ 10 - 9
ui/package.json

@@ -1,16 +1,15 @@
 {
-  "name": "kubecost-ui-open",
-  "version": "0.0.1",
-  "description": "Open source UI for Kubecost",
+  "name": "opencost-ui",
+  "description": "Open source UI for OpenCost",
+  "version": "0.1.0",
+  "license": "Apache-2.0",
   "scripts": {
     "build": "npx parcel build src/index.html",
-    "serve": "npx parcel serve src/index.html",
+    "serve": "npx parcel serve src/index.html --no-cache",
     "clean": "rm -rf dist/*",
     "test": "echo \"Error: no test specified\" && exit 1",
     "preinstall": "npx npm-force-resolutions"
   },
-  "author": "",
-  "license": "Apache-2.0",
   "browserslist": [
     "defaults"
   ],
@@ -21,9 +20,9 @@
     "@material-ui/core": "^4.11.3",
     "@material-ui/icons": "^4.11.2",
     "@material-ui/pickers": "^3.3.10",
-    "@material-ui/styles": "^4.11.3",
-    "axios": "^0.21.2",
-    "date-fns": "^2.19.0",
+    "@material-ui/styles": "^4.11.5",
+    "axios": "^1.4.0",
+    "date-fns": "^2.30.0",
     "material-design-icons-iconfont": "^6.1.0",
     "prop-types": "^15.7.2",
     "react": "^17.0.1",
@@ -36,7 +35,9 @@
     "@babel/plugin-proposal-class-properties": "^7.13.0",
     "@babel/plugin-transform-runtime": "^7.13.10",
     "@babel/preset-react": "^7.12.13",
+    "buffer": "^6.0.3",
     "parcel": "^2.2.1",
+    "process": "^0.11.10",
     "set-value": "4.0.1"
   },
   "resolutions": {

+ 203 - 175
ui/src/Reports.js

@@ -1,201 +1,229 @@
-import CircularProgress from '@material-ui/core/CircularProgress'
-import IconButton from '@material-ui/core/IconButton'
-import Paper from '@material-ui/core/Paper'
-import Typography from '@material-ui/core/Typography'
-import RefreshIcon from '@material-ui/icons/Refresh'
-import { makeStyles } from '@material-ui/styles'
-import { filter, find, forEach, get, isArray, sortBy, toArray, trim } from 'lodash'
-import React, { useEffect, useState } from 'react'
-import ReactDOM from 'react-dom'
-import { useLocation, useHistory } from 'react-router';
+import CircularProgress from "@material-ui/core/CircularProgress";
+import IconButton from "@material-ui/core/IconButton";
+import Paper from "@material-ui/core/Paper";
+import Typography from "@material-ui/core/Typography";
+import RefreshIcon from "@material-ui/icons/Refresh";
+import { makeStyles } from "@material-ui/styles";
+import {
+  filter,
+  find,
+  forEach,
+  get,
+  isArray,
+  sortBy,
+  toArray,
+  trim,
+} from "lodash";
+import React, { useEffect, useState } from "react";
+import ReactDOM from "react-dom";
+import { useLocation, useHistory } from "react-router";
 
-import AllocationReport from './components/AllocationReport';
-import Controls from './components/Controls';
-import Header from './components/Header';
-import Page from './components/Page';
-import Subtitle from './components/Subtitle';
-import Warnings from './components/Warnings';
-import AllocationService from './services/allocation';
-import { checkCustomWindow, cumulativeToTotals, rangeToCumulative, toVerboseTimeRange } from './util';
-import { currencyCodes } from './constants/currencyCodes'
+import AllocationReport from "./components/allocationReport";
+import Controls from "./components/Controls";
+import Header from "./components/Header";
+import Page from "./components/Page";
+import Subtitle from "./components/Subtitle";
+import Warnings from "./components/Warnings";
+import AllocationService from "./services/allocation";
+import {
+  checkCustomWindow,
+  cumulativeToTotals,
+  rangeToCumulative,
+  toVerboseTimeRange,
+} from "./util";
+import { currencyCodes } from "./constants/currencyCodes";
 
 const windowOptions = [
-  { name: 'Today', value: 'today' },
-  { name: 'Yesterday', value: 'yesterday' },
-  { name: 'Week-to-date', value: 'week' },
-  { name: 'Month-to-date', value: 'month' },
-  { name: 'Last week', value: 'lastweek' },
-  { name: 'Last month', value: 'lastmonth' },
-  { name: 'Last 7 days', value: '6d' },
-  { name: 'Last 30 days', value: '29d' },
-  { name: 'Last 60 days', value: '59d' },
-  { name: 'Last 90 days', value: '89d' },
-]
+  { name: "Today", value: "today" },
+  { name: "Yesterday", value: "yesterday" },
+  { name: "Last 24h", value: "24h" },
+  { name: "Last 48h", value: "48h" },
+  { name: "Week-to-date", value: "week" },
+  { name: "Last week", value: "lastweek" },
+  { name: "Last 7 days", value: "7d" },
+  { name: "Last 14 days", value: "14d" },
+];
 
 const aggregationOptions = [
-  { name: 'Cluster', value: 'cluster' },
-  { name: 'Node', value: 'node' },
-  { name: 'Namespace', value: 'namespace' },
-  { name: 'Controller kind', value: 'controllerKind' },
-  { name: 'Controller', value: 'controller' },
-  { name: 'Service', value: 'service' },
-  { name: 'Pod', value: 'pod' },
-  { name: 'Container', value: 'container' },
-]
+  { name: "Cluster", value: "cluster" },
+  { name: "Node", value: "node" },
+  { name: "Namespace", value: "namespace" },
+  { name: "Controller kind", value: "controllerKind" },
+  { name: "Controller", value: "controller" },
+  { name: "Service", value: "service" },
+  { name: "Pod", value: "pod" },
+  { name: "Deployment", value: "deployment" },
+  { name: "Container", value: "container" },
+];
 
 const accumulateOptions = [
-  { name: 'Entire window', value: true },
-  { name: 'Daily', value: false },
-]
+  { name: "Entire window", value: true },
+  { name: "Daily", value: false },
+];
 
 const useStyles = makeStyles({
   reportHeader: {
-    display: 'flex',
-    flexFlow: 'row',
+    display: "flex",
+    flexFlow: "row",
     padding: 24,
   },
   titles: {
     flexGrow: 1,
   },
-})
+});
 
 // generateTitle generates a string title from a report object
 function generateTitle({ window, aggregateBy, accumulate }) {
-  let windowName = get(find(windowOptions, { value: window }), 'name', '')
-  if (windowName === '') {
+  let windowName = get(find(windowOptions, { value: window }), "name", "");
+  if (windowName === "") {
     if (checkCustomWindow(window)) {
-      windowName = toVerboseTimeRange(window)
+      windowName = toVerboseTimeRange(window);
     } else {
-      console.warn(`unknown window: ${window}`)
+      console.warn(`unknown window: ${window}`);
     }
   }
 
-  let aggregationName = get(find(aggregationOptions, { value: aggregateBy }), 'name', '').toLowerCase()
-  if (aggregationName === '') {
-    console.warn(`unknown aggregation: ${aggregateBy}`)
+  let aggregationName = get(
+    find(aggregationOptions, { value: aggregateBy }),
+    "name",
+    ""
+  ).toLowerCase();
+  if (aggregationName === "") {
+    console.warn(`unknown aggregation: ${aggregateBy}`);
   }
 
-  let str = `${windowName} by ${aggregationName}`
+  let str = `${windowName} by ${aggregationName}`;
 
   if (!accumulate) {
-    str = `${str} daily`
+    str = `${str} daily`;
   }
 
-  return str
+  return str;
 }
 
-
 const ReportsPage = () => {
-  const classes = useStyles()
+  const classes = useStyles();
 
   // Allocation data state
-  const [allocationData, setAllocationData] = useState([])
-  const [cumulativeData, setCumulativeData] = useState({})
-  const [totalData, setTotalData] = useState({})
+  const [allocationData, setAllocationData] = useState([]);
+  const [cumulativeData, setCumulativeData] = useState({});
+  const [totalData, setTotalData] = useState({});
 
   // When allocation data changes, create a cumulative version of it
   useEffect(() => {
-    const cumulative = rangeToCumulative(allocationData, aggregateBy)
-    setCumulativeData(toArray(cumulative))
-    setTotalData(cumulativeToTotals(cumulative))
-  }, [allocationData])
+    const cumulative = rangeToCumulative(allocationData, aggregateBy);
+    setCumulativeData(toArray(cumulative));
+    setTotalData(cumulativeToTotals(cumulative));
+  }, [allocationData]);
 
   // Form state, which controls form elements, but not the report itself. On
   // certain actions, the form state may flow into the report state.
-  const [window, setWindow] = useState(windowOptions[0].value)
-  const [aggregateBy, setAggregateBy] = useState(aggregationOptions[0].value)
-  const [accumulate, setAccumulate] = useState(accumulateOptions[0].value)
-  const [currency, setCurrency] = useState('USD')
+  const [window, setWindow] = useState(windowOptions[0].value);
+  const [aggregateBy, setAggregateBy] = useState(aggregationOptions[0].value);
+  const [accumulate, setAccumulate] = useState(accumulateOptions[0].value);
+  const [currency, setCurrency] = useState("USD");
 
   // Report state, including current report and saved options
-  const [title, setTitle] = useState('Last 7 days by namespace daily')
+  const [title, setTitle] = useState("Last 7 days by namespace daily");
 
   // When parameters changes, fetch data. This should be the
   // only mechanism used to fetch data. Also generate a sensible title from the paramters.
   useEffect(() => {
-    setFetch(true)
-    setTitle(generateTitle({ window, aggregateBy, accumulate }))
-  }, [window, aggregateBy, accumulate])
+    setFetch(true);
+    setTitle(generateTitle({ window, aggregateBy, accumulate }));
+  }, [window, aggregateBy, accumulate]);
 
   // page and settings state
-  const [init, setInit] = useState(false)
-  const [fetch, setFetch] = useState(false)
-  const [loading, setLoading] = useState(true)
-  const [errors, setErrors] = useState([])
+  const [init, setInit] = useState(false);
+  const [fetch, setFetch] = useState(false);
+  const [loading, setLoading] = useState(true);
+  const [errors, setErrors] = useState([]);
 
   // Initialize once, then fetch report each time setFetch(true) is called
   useEffect(() => {
     if (!init) {
-      initialize()
+      initialize();
     }
-    if (init && fetch) {
-      fetchData()
+    if (init || fetch) {
+      fetchData();
     }
-  }, [init, fetch])
+  }, [init, fetch]);
 
   // parse any context information from the URL
   const routerLocation = useLocation();
   const searchParams = new URLSearchParams(routerLocation.search);
   const routerHistory = useHistory();
   useEffect(() => {
-    setWindow(searchParams.get('window') || '6d');
-    setAggregateBy(searchParams.get('agg') || 'namespace');
-    setAccumulate((searchParams.get('acc') === 'true') || false);
-    setCurrency(searchParams.get('currency') || 'USD');
+    setWindow(searchParams.get("window") || "6d");
+    setAggregateBy(searchParams.get("agg") || "namespace");
+    setAccumulate(searchParams.get("acc") === "true" || false);
+    setCurrency(searchParams.get("currency") || "USD");
   }, [routerLocation]);
 
   async function initialize() {
-    setInit(true)
+    setInit(true);
   }
 
   async function fetchData() {
-    setLoading(true)
-    setErrors([])
+    setLoading(true);
+    setErrors([]);
 
     try {
-      const resp = await AllocationService.fetchAllocation(window, aggregateBy, { accumulate })
+      const resp = await AllocationService.fetchAllocation(
+        window,
+        aggregateBy,
+        { accumulate }
+      );
       if (resp.data && resp.data.length > 0) {
-        const allocationRange = resp.data
+        const allocationRange = resp.data;
         for (const i in allocationRange) {
           // update cluster aggregations to use clusterName/clusterId names
-          allocationRange[i] = sortBy(allocationRange[i], a => a.totalCost)
+          allocationRange[i] = sortBy(allocationRange[i], (a) => a.totalCost);
         }
-        setAllocationData(allocationRange)
+        setAllocationData(allocationRange);
       } else {
-        if (resp.message && resp.message.indexOf('boundary error') >= 0) {
-          let match = resp.message.match(/(ETL is \d+\.\d+% complete)/)
-          let secondary = 'Try again after ETL build is complete'
+        if (resp.message && resp.message.indexOf("boundary error") >= 0) {
+          let match = resp.message.match(/(ETL is \d+\.\d+% complete)/);
+          let secondary = "Try again after ETL build is complete";
           if (match.length > 0) {
-            secondary = `${match[1]}. ${secondary}`
+            secondary = `${match[1]}. ${secondary}`;
           }
-          setErrors([{
-            primary: 'Data unavailable while ETL is building',
-            secondary: secondary,
-          }])
+          setErrors([
+            {
+              primary: "Data unavailable while ETL is building",
+              secondary: secondary,
+            },
+          ]);
         }
-        setAllocationData([])
+        setAllocationData([]);
       }
     } catch (err) {
-      if (err.message.indexOf('404') === 0) {
-        setErrors([{
-          primary: 'Failed to load report data',
-          secondary: 'Please update Kubecost to the latest version, then contact support if problems persist.'
-        }])
+      if (err.message.indexOf("404") === 0) {
+        setErrors([
+          {
+            primary: "Failed to load report data",
+            secondary:
+              "Please update Kubecost to the latest version, then contact support if problems persist.",
+          },
+        ]);
       } else {
-        let secondary = 'Please contact Kubecost support with a bug report if problems persist.'
+        let secondary =
+          "Please contact Kubecost support with a bug report if problems persist.";
         if (err.message.length > 0) {
-          secondary = err.message
+          secondary = err.message;
         }
-        setErrors([{
-          primary: 'Failed to load report data',
-          secondary: secondary,
-        }])
+        setErrors([
+          {
+            primary: "Failed to load report data",
+            secondary: secondary,
+          },
+        ]);
       }
-      setAllocationData([])
+      setAllocationData([]);
     }
 
-    setLoading(false)
-    setFetch(false)
+    setLoading(false);
+    setFetch(false);
   }
   return (
     <Page active="reports.html">
@@ -211,71 +239,71 @@ const ReportsPage = () => {
         </div>
       )}
 
-      {init && <Paper id="report">
-        <div className={classes.reportHeader}>
-          <div className={classes.titles}>
-            <Typography variant="h5">{title}</Typography>
-            <Subtitle
-              report={{ window, aggregateBy, accumulate }}
+      {init && (
+        <Paper id="report">
+          <div className={classes.reportHeader}>
+            <div className={classes.titles}>
+              <Typography variant="h5">{title}</Typography>
+              <Subtitle report={{ window, aggregateBy, accumulate }} />
+            </div>
+
+            <Controls
+              windowOptions={windowOptions}
+              window={window}
+              setWindow={(win) => {
+                searchParams.set("window", win);
+                routerHistory.push({
+                  search: `?${searchParams.toString()}`,
+                });
+              }}
+              aggregationOptions={aggregationOptions}
+              aggregateBy={aggregateBy}
+              setAggregateBy={(agg) => {
+                searchParams.set("agg", agg);
+                routerHistory.push({
+                  search: `?${searchParams.toString()}`,
+                });
+              }}
+              accumulateOptions={accumulateOptions}
+              accumulate={accumulate}
+              setAccumulate={(acc) => {
+                searchParams.set("acc", acc);
+                routerHistory.push({
+                  search: `?${searchParams.toString()}`,
+                });
+              }}
+              title={title}
+              cumulativeData={cumulativeData}
+              currency={currency}
+              currencyOptions={currencyCodes}
+              setCurrency={(curr) => {
+                searchParams.set("currency", curr);
+                routerHistory.push({
+                  search: `?${searchParams.toString()}`,
+                });
+              }}
             />
           </div>
 
-          <Controls
-            windowOptions={windowOptions}
-            window={window}
-            setWindow={(win) => {
-              searchParams.set('window', win);
-              routerHistory.push({
-                search: `?${searchParams.toString()}`,
-              });
-            }}
-            aggregationOptions={aggregationOptions}
-            aggregateBy={aggregateBy}
-            setAggregateBy={(agg) => {
-              searchParams.set('agg', agg);
-              routerHistory.push({
-                search: `?${searchParams.toString()}`,
-              });
-            }}
-            accumulateOptions={accumulateOptions}
-            accumulate={accumulate}
-            setAccumulate={(acc) => {
-              searchParams.set('acc', acc);
-              routerHistory.push({
-                search: `?${searchParams.toString()}`
-              });
-            }}
-            title={title}
-            cumulativeData={cumulativeData}
-            currency={currency}
-            currencyOptions={currencyCodes}
-            setCurrency={(curr) => {
-              searchParams.set('currency', curr);
-              routerHistory.push({
-                search: `?${searchParams.toString()}`
-              });
-            }}
-          />
-        </div>
-
-        {loading && (
-          <div style={{ display: 'flex', justifyContent: 'center' }}>
-            <div style={{ paddingTop: 100, paddingBottom: 100 }}>
-              <CircularProgress />
+          {loading && (
+            <div style={{ display: "flex", justifyContent: "center" }}>
+              <div style={{ paddingTop: 100, paddingBottom: 100 }}>
+                <CircularProgress />
+              </div>
             </div>
-          </div>
-        )}
-        {!loading && (
-          <AllocationReport
-            allocationData={allocationData}
-            cumulativeData={cumulativeData}
-            totalData={totalData}
-            currency={currency}
-          />
-        )}
-      </Paper>}
+          )}
+          {!loading && (
+            <AllocationReport
+              allocationData={allocationData}
+              cumulativeData={cumulativeData}
+              totalData={totalData}
+              currency={currency}
+            />
+          )}
+        </Paper>
+      )}
     </Page>
-  )
-}
+  );
+};
 
 export default React.memo(ReportsPage);

+ 4 - 17
ui/src/app.js

@@ -1,18 +1,5 @@
-import React from 'react';
-import ReactDOM from 'react-dom';
-import { BrowserRouter as Router } from 'react-router-dom';
+import * as React from "react";
+import ReactDOM from "react-dom";
+import Routes from "./route";
 
-import Reports from './Reports.js';
-
-function ReportsPage() {
-  return (
-    <Router>
-      <Reports path="/" />
-    </Router>
-  );
-}
-
-ReactDOM.render(
-  <ReportsPage />,
-  document.getElementById('app')
-);
+ReactDOM.render(<Routes />, document.getElementById("app"));

+ 217 - 0
ui/src/cloudCost/cloudCost.js

@@ -0,0 +1,217 @@
+import * as React from "react";
+import { get } from "lodash";
+import { makeStyles } from "@material-ui/styles";
+import {
+  Typography,
+  TableContainer,
+  TableCell,
+  TableHead,
+  TablePagination,
+  TableRow,
+  TableSortLabel,
+  Table,
+  TableBody,
+} from "@material-ui/core";
+
+import { toCurrency } from "../util";
+import CloudCostChart from "./cloudCostChart";
+import { CloudCostRow } from "./cloudCostRow";
+
+const CloudCost = ({
+  cumulativeData = [],
+  totalData: totalsRow = {},
+  graphData = [],
+  currency = "USD",
+  drilldown,
+  sampleData = false,
+}) => {
+  const useStyles = makeStyles({
+    noResults: {
+      padding: 24,
+    },
+  });
+
+ 
+
+  const classes = useStyles();
+
+  function descendingComparator(a, b, orderBy) {
+    if (get(b, orderBy) < get(a, orderBy)) {
+      return -1;
+    }
+    if (get(b, orderBy) > get(a, orderBy)) {
+      return 1;
+    }
+    return 0;
+  }
+
+  function getComparator(order, orderBy) {
+    return order === "desc"
+      ? (a, b) => descendingComparator(a, b, orderBy)
+      : (a, b) => -descendingComparator(a, b, orderBy);
+  }
+
+  function stableSort(array, comparator) {
+    const stabilizedThis = array.map((el, index) => [el, index]);
+    stabilizedThis.sort((a, b) => {
+      const order = comparator(a[0], b[0]);
+      if (order !== 0) return order;
+      return a[1] - b[1];
+    });
+    return stabilizedThis.map((el) => el[0]);
+  }
+
+  const headCells = [
+    {
+      id: "name",
+      numeric: false,
+      label: "Name",
+      width: "auto",
+    },
+    {
+      id: "kubernetesPercent",
+      numeric: true,
+      label: "K8s Utilization",
+      width: 160,
+    },
+    sampleData
+      ? {
+          id: "cost",
+          numeric: true,
+          label: "Sum of Sample Data",
+          width: 200,
+        }
+      : {
+          id: "cost",
+          numeric: true,
+          label: "Total cost",
+          width: 155,
+        },
+  ];
+
+  const [order, setOrder] = React.useState("desc");
+  const [orderBy, setOrderBy] = React.useState("totalCost");
+  const [page, setPage] = React.useState(0);
+  const [rowsPerPage, setRowsPerPage] = React.useState(25);
+  const numData = cumulativeData?.length;
+
+  const lastPage = Math.floor(numData / rowsPerPage);
+
+  const handleChangePage = (event, newPage) => setPage(newPage);
+
+  const handleChangeRowsPerPage = (event) => {
+    setRowsPerPage(parseInt(event.target.value, 10));
+    setPage(0);
+  };
+
+  const orderedRows = stableSort(cumulativeData, getComparator(order, orderBy));
+  const pageRows = orderedRows.slice(
+    page * rowsPerPage,
+    page * rowsPerPage + rowsPerPage
+  );
+
+  React.useEffect(() => {
+    setPage(0);
+  }, [numData]);
+
+  if (cumulativeData.length === 0) {
+    return (
+      <Typography variant="body2" className={classes.noResults}>
+        No results
+      </Typography>
+    );
+  }
+
+  function dataToCloudCostRow(row) {
+    const suffix =
+      { hourly: "/hr", monthly: "/mo", daily: "/day" }["cumulative"] || "";
+    return (
+      <CloudCostRow
+        costSuffix={suffix}
+        cost={row.cost}
+        drilldown={drilldown}
+        key={row.name}
+        kubernetesPercent={row.kubernetesPercent}
+        name={
+          sampleData && row.labelName ? row.labelName ?? "" : row.name ?? ""
+        }
+        row={row}
+        sampleData={sampleData}
+      />
+    );
+  }
+
+  return (
+    <div id="cloud-cost">
+      <div id="cloud-graph-">
+        <CloudCostChart
+          currency={currency}
+          graphData={graphData}
+          height={300}
+          n={10}
+        />
+      </div>
+      <div id="cloud-cost-table">
+        <TableContainer>
+          <Table>
+            <TableHead>
+              <TableRow>
+                {headCells.map((cell) => (
+                  <TableCell
+                    key={cell.id}
+                    colSpan={cell.colspan}
+                    align={cell.numeric ? "right" : "left"}
+                    sortDirection={orderBy === cell.id ? order : false}
+                    style={{ width: cell.width }}
+                  >
+                    <TableSortLabel
+                      active={orderBy === cell.id}
+                      direction={orderBy === cell.id ? order : "asc"}
+                      onClick={() => {
+                        const isDesc = orderBy === cell.id && order === "desc";
+                        setOrder(isDesc ? "asc" : "desc");
+                        setOrderBy(cell.id);
+                      }}
+                    >
+                      {cell.label}
+                    </TableSortLabel>
+                  </TableCell>
+                ))}
+              </TableRow>
+            </TableHead>
+            <TableBody>
+              <TableRow>
+                <TableCell align={"left"} style={{ fontWeight: 500 }}>
+                  {totalsRow?.name || "Totals"}
+                </TableCell>
+
+                <TableCell align={"right"} style={{ fontWeight: 500 }}>
+                  {Math.round(totalsRow?.kubernetesPercent * 100)}%
+                </TableCell>
+
+                <TableCell
+                  align={"right"}
+                  style={{ fontWeight: 500, paddingRight: "2em" }}
+                >
+                  {toCurrency(totalsRow?.cost || 0, currency)}
+                </TableCell>
+              </TableRow>
+              {pageRows.map(dataToCloudCostRow)}
+            </TableBody>
+          </Table>
+        </TableContainer>
+        <TablePagination
+          component="div"
+          count={numData}
+          rowsPerPage={rowsPerPage}
+          rowsPerPageOptions={[10, 25, 50]}
+          page={Math.min(page, lastPage)}
+          onChangePage={handleChangePage}
+          onChangeRowsPerPage={handleChangeRowsPerPage}
+        />
+      </div>
+    </div>
+  );
+};
+
+export default React.memo(CloudCost);

+ 14 - 0
ui/src/cloudCost/cloudCostChart/index.js

@@ -0,0 +1,14 @@
+import * as React from "react";
+
+import Typography from "@material-ui/core/Typography";
+
+import RangeChart from "./rangeChart";
+
+const CloudCostChart = ({ graphData, currency, n, height }) => {
+  if (graphData.length === 0) {
+    return <Typography variant="body2">No data</Typography>;
+  }
+  return <RangeChart data={graphData} currency={currency} height={height} />;
+};
+
+export default React.memo(CloudCostChart);

+ 275 - 0
ui/src/cloudCost/cloudCostChart/rangeChart.js

@@ -0,0 +1,275 @@
+import * as React from "react";
+import { makeStyles } from "@material-ui/styles";
+import {
+  BarChart,
+  Bar,
+  XAxis,
+  YAxis,
+  CartesianGrid,
+  Tooltip,
+  ResponsiveContainer,
+  Cell,
+} from "recharts";
+import { primary, greyscale, browns } from "../../constants/colors";
+import { toCurrency } from "../../util";
+
+const RangeChart = ({ data, currency, height }) => {
+  const useStyles = makeStyles({
+    tooltip: {
+      borderRadius: 2,
+      background: "rgba(255, 255, 255, 0.95)",
+      padding: 12,
+    },
+    tooltipLineItem: {
+      fontSize: "1rem",
+      margin: 0,
+      marginBottom: 4,
+      padding: 0,
+    },
+  });
+
+  const accents = [...primary, ...greyscale, ...browns];
+
+  const _IDLE_ = "__idle__";
+  const _OTHER_ = "others";
+
+  const getItemCost = (item) => {
+    return item.value;
+  };
+
+  function toBar({ end, graph, start }) {
+    const points = graph.map((item) => ({
+      ...item,
+      window: { end, start },
+    }));
+
+    const dateFormatter = Intl.DateTimeFormat(navigator.language, {
+      year: "numeric",
+      month: "numeric",
+      day: "numeric",
+      timeZone: "UTC",
+    });
+
+    const timeFormatter = Intl.DateTimeFormat(navigator.language, {
+      hour: "numeric",
+      minute: "numeric",
+      timeZone: "UTC",
+    });
+
+    const s = new Date(start);
+    const e = new Date(end);
+    const interval = (e.valueOf() - s.valueOf()) / 1000 / 60 / 60;
+
+    const bar = {
+      end: new Date(end),
+      key: interval >= 24 ? dateFormatter.format(s) : timeFormatter.format(s),
+      items: {},
+      start: new Date(start),
+    };
+
+    points.forEach((item) => {
+      const windowStart = new Date(item.window.start);
+      const windowEnd = new Date(item.window.end);
+      const windowHours =
+        (windowEnd.valueOf() - windowStart.valueOf()) / 1000 / 60 / 60;
+
+      if (windowHours >= 24) {
+        bar.key = dateFormatter.format(bar.start);
+      } else {
+        bar.key = timeFormatter.format(bar.start);
+      }
+
+      bar.items[item.name] = getItemCost(item);
+    });
+
+    return bar;
+  }
+
+  const getDataForCloudDay = (dayData) => {
+    const { end, start } = dayData;
+    const copy = [...dayData.items];
+
+    // find items for idle and other
+    const idleIndex = copy.findIndex((item) => item.name === _IDLE_);
+    let idle = undefined;
+    if (idleIndex > -1) {
+      idle = copy[idleIndex];
+      copy.splice(idleIndex, 1);
+    }
+    const otherIndex = copy.findIndex(
+      (i) => i.name === _OTHER_ || i.name === "other"
+    );
+    let other = undefined;
+    if (otherIndex > -1) {
+      other = { ...copy[otherIndex], name: "other" };
+      copy.splice(otherIndex, 1);
+    }
+
+    // sort and remove any items < top 8
+    const sortedItems = copy.slice().sort((a, b) => {
+      return a.value > b.value ? -1 : 1;
+    });
+
+    const top8 = sortedItems.slice(0, 8);
+    // get items that didn't make the cut and shove into other
+    const lefovers = sortedItems.slice(8);
+    if (lefovers.length > 0) {
+      const othersTotal = lefovers.reduce((a, b) => a.value + b.value);
+      if (other) {
+        other.value += othersTotal;
+      } else if (othersTotal) {
+        other = {
+          name: "other",
+          value: othersTotal,
+        };
+      }
+    }
+    // add in idle and other
+    if (idle) {
+      top8.unshift(idle);
+    }
+    if (other) {
+      top8.unshift(other);
+    }
+
+    return { end, start, graph: top8 };
+  };
+
+  const getDataForGraph = (dataPoints) => {
+    // for each day, we want top 8 + Idle and Other
+    const orderedDataPoints = dataPoints.map(getDataForCloudDay);
+    const bars = orderedDataPoints.map(toBar);
+
+    const keyToFill = {};
+    // we want to keep track of the order of fill assignment
+    const assignmentOrder = [];
+    let p = 0;
+
+    orderedDataPoints.forEach(({ graph, start, end }) => {
+      graph.forEach(({ name }) => {
+        const key = name;
+        if (keyToFill[key] === undefined) {
+          assignmentOrder.push(key);
+          if (key === _IDLE_) {
+            keyToFill[key] = browns;
+          } else if (key === _OTHER_ || key === "other") {
+            keyToFill[key] = greyscale;
+          } else {
+            // non-idle/other allocations get the next available color
+            keyToFill[key] = accents[p];
+            p = (p + 1) % accents.length;
+          }
+        }
+      });
+    });
+    // list of dataKeys and fillColors in order of importance (price w/ 'others' last)
+    const labels = assignmentOrder.map((dataKey) => ({
+      dataKey,
+      fill: keyToFill[dataKey],
+    }));
+
+    return { bars, labels, keyToFill };
+  };
+
+  const { bars: barData, labels: barLabels, keyToFill } = getDataForGraph(data);
+
+  const classes = useStyles();
+
+  const CustomTooltip = (params) => {
+    const { active, payload } = params;
+
+    if (!payload || payload.length == 0) {
+      return null;
+    }
+
+    const total = payload.reduce((sum, item) => sum + item.value, 0.0);
+    if (active) {
+      return (
+        <div className={classes.tooltip}>
+          <p
+            className={classes.tooltipLineItem}
+            style={{ color: "#000000" }}
+          >{`Total: ${toCurrency(total, currency)}`}</p>
+
+          {payload
+            .slice()
+            .map((item, i) => (
+              <div
+                key={item.name}
+                style={{
+                  display: "grid",
+                  gridTemplateColumns: "20px 1fr",
+                  gap: ".5em",
+                  margin: ".25em",
+                }}
+              >
+                <div>
+                  <div
+                    style={{
+                      backgroundColor: keyToFill[item.payload.items[i][0]],
+                      width: 18,
+                      height: 18,
+                    }}
+                  />
+                </div>
+                <div>
+                  <p className={classes.tooltipLineItem}>{`${
+                    item.payload.items[i][0]
+                  }: ${toCurrency(item.value, currency)}`}</p>
+                </div>
+              </div>
+            ))
+            .reverse()}
+        </div>
+      );
+    }
+
+    return null;
+  };
+
+  const orderedBars = barData.map((bar) => {
+    return {
+      ...bar,
+      items: Object.entries(bar.items).sort((a, b) => {
+        if (a[0] === "other") {
+          return -1;
+        }
+        if (b[0] === "other") {
+          return 1;
+        }
+        return a[1] > b[1] ? -1 : 1;
+      }),
+    };
+  });
+
+  return (
+    <ResponsiveContainer height={height} width={"100%"}>
+      <BarChart
+        data={orderedBars}
+        margin={{ top: 30, right: 35, left: 30, bottom: 45 }}
+      >
+        <CartesianGrid strokeDasharray={"3 3"} vertical={false} />
+        <XAxis dataKey={"key"} />
+        <YAxis tickFormatter={(val) => toCurrency(val, currency, 2, true)} />
+        <Tooltip content={<CustomTooltip />} wrapperStyle={{ zIndex: 1000 }} />
+
+        {new Array(10).fill(0).map((item, idx) => (
+          <Bar
+            dataKey={(entry) => (entry.items[idx] ? entry.items[idx][1] : null)}
+            stackId="x"
+          >
+            {orderedBars.map((bar) =>
+              bar.items[idx] ? (
+                <Cell fill={keyToFill[bar.items[idx][0]]} />
+              ) : (
+                <Cell />
+              )
+            )}
+          </Bar>
+        ))}
+      </BarChart>
+    </ResponsiveContainer>
+  );
+};
+
+export default RangeChart;

+ 178 - 0
ui/src/cloudCost/cloudCostDetails.js

@@ -0,0 +1,178 @@
+import * as React from "react";
+import { Modal, Paper, Typography } from "@material-ui/core";
+import Warnings from "../components/Warnings";
+import CircularProgress from "@material-ui/core/CircularProgress";
+
+import {
+  ResponsiveContainer,
+  CartesianGrid,
+  Legend,
+  XAxis,
+  YAxis,
+  Tooltip,
+  BarChart,
+  Bar,
+} from "recharts";
+import { toCurrency } from "../util";
+import cloudCostDayTotals from "../services/cloudCostDayTotals";
+
+const CloudCostDetails = ({
+  onClose,
+  selectedProviderId,
+  selectedItem,
+  agg,
+  filters,
+  costMetric,
+  window,
+  currency,
+}) => {
+  const [data, setData] = React.useState([]);
+  const [loading, setLoading] = React.useState(false);
+  const [errors, setErrors] = React.useState([]);
+  const [fetch, setFetch] = React.useState(true);
+
+  const nextFilters = [
+    ...(filters ?? []),
+    { property: "providerIds", value: selectedProviderId },
+  ];
+
+  async function fetchData() {
+    setLoading(true);
+    setErrors([]);
+
+    try {
+      const resp = await cloudCostDayTotals.fetchCloudCostData(
+        window,
+        agg,
+        costMetric,
+        nextFilters
+      );
+
+      if (resp.data) {
+        setData(resp.data);
+      } else {
+        if (resp.message && resp.message.indexOf("boundary error") >= 0) {
+          let match = resp.message.match(/(ETL is \d+\.\d+% complete)/);
+          let secondary = "Try again after ETL build is complete";
+          if (match.length > 0) {
+            secondary = `${match[1]}. ${secondary}`;
+          }
+          setErrors([
+            {
+              primary: "Data unavailable while ETL is building",
+              secondary: secondary,
+            },
+          ]);
+        }
+        setData([]);
+      }
+    } catch (err) {
+      console.log(err);
+      if (err.message.indexOf("404") === 0) {
+        setErrors([
+          {
+            primary: "Failed to load report data",
+            secondary:
+              "Please update Kubecost to the latest version, then contact support if problems persist.",
+          },
+        ]);
+      } else {
+        let secondary =
+          "Please contact Kubecost support with a bug report if problems persist.";
+        if (err.message.length > 0) {
+          secondary = err.message;
+        }
+        setErrors([
+          {
+            primary: "Failed to load report data",
+            secondary: secondary,
+          },
+        ]);
+      }
+      setData([]);
+    }
+    setLoading(false);
+    setFetch(false);
+  }
+
+  React.useEffect(() => {
+    if (fetch) {
+      fetchData();
+    }
+  }, [fetch]);
+
+  const drilldownData = data.sort(
+    (a, b) =>
+      new Date(a.date ?? "").getTime() - new Date(b.date ?? "").getTime()
+  );
+
+  const itemData = drilldownData.map((items) => {
+    const dataPoint = {
+      time: new Date(items.date),
+      cost: items.cost,
+    };
+    return dataPoint;
+  });
+
+  return (
+    <div>
+      <Modal
+        open={true}
+        onClose={onClose}
+        title={`Costs over the last ${window}`}
+        style={{ margin: "10%" }}
+      >
+        <Paper>
+          <Typography style={{ marginTop: "1rem" }} variant="body1">
+            {selectedItem}
+          </Typography>
+
+          {loading && (
+            <div style={{ display: "flex", justifyContent: "center" }}>
+              <div style={{ paddingTop: 100, paddingBottom: 100 }}>
+                <CircularProgress />
+              </div>
+            </div>
+          )}
+          {!loading && errors.length > 0 && (
+            <div style={{ marginBottom: 20 }}>
+              <Warnings warnings={errors} />
+            </div>
+          )}
+          {data && (
+            <div style={{ display: "flex", marginTop: "2.5rem" }}>
+              <ResponsiveContainer
+                height={250}
+                id={"cloud-cost-drilldown"}
+                width={"100%"}
+              >
+                <BarChart
+                  data={itemData}
+                  margin={{
+                    top: 0,
+                    bottom: 10,
+                    left: 20,
+                    right: 0,
+                  }}
+                >
+                  <CartesianGrid vertical={false} />
+                  <Legend verticalAlign={"bottom"} />
+                  <XAxis dataKey={"time"} />
+                  <YAxis tickFormatter={(tick) => `${toCurrency(tick)}`} />
+                  <Bar dataKey={"cost"} fill={"#2196f3"} name={"Item Cost"} />
+                  <Tooltip
+                    formatter={(value) =>
+                      `${toCurrency(value ?? 0, currency, 4, true)}`
+                    }
+                  />
+                </BarChart>
+              </ResponsiveContainer>
+            </div>
+          )}
+        </Paper>
+      </Modal>
+    </div>
+  );
+};
+
+export { CloudCostDetails };

+ 48 - 0
ui/src/cloudCost/cloudCostRow.js

@@ -0,0 +1,48 @@
+import * as React from "react";
+
+import { TableCell, TableRow } from "@material-ui/core";
+
+import { toCurrency } from "../util";
+import { primary } from "../constants/colors";
+
+const displayCurrencyAsLessThanPenny = (amount, currency) =>
+  amount > 0 && amount < 0.01
+    ? `<${toCurrency(0.01, currency)}`
+    : toCurrency(amount, currency);
+
+const CloudCostRow = ({
+  cost,
+  costSuffix,
+  currency,
+  drilldown,
+  kubernetesPercent,
+  name,
+  row,
+  sampleData,
+}) => {
+  function calculatePercent() {
+    const totalPercent = (kubernetesPercent * 100).toFixed();
+    return `${totalPercent}%`;
+  }
+
+  const whichPercent = sampleData
+    ? `${(kubernetesPercent * 100).toFixed(1)}%`
+    : calculatePercent();
+  return (
+    <TableRow onClick={() => drilldown(row)}>
+      <TableCell
+        align={"left"}
+        style={{ cursor: "pointer", color: "#346ef2", padding: "1rem" }}
+      >
+        {name}
+      </TableCell>
+      <TableCell align={"right"}>{whichPercent}</TableCell>
+      {/* total cost */}
+      <TableCell align={"right"} style={{ paddingRight: "2em" }}>
+        {`${displayCurrencyAsLessThanPenny(cost, currency)}${costSuffix}`}
+      </TableCell>
+    </TableRow>
+  );
+};
+
+export { CloudCostRow };

+ 91 - 0
ui/src/cloudCost/controls/cloudCostEditControls.js

@@ -0,0 +1,91 @@
+import { makeStyles } from "@material-ui/styles";
+import FormControl from "@material-ui/core/FormControl";
+import InputLabel from "@material-ui/core/InputLabel";
+import MenuItem from "@material-ui/core/MenuItem";
+import Select from "@material-ui/core/Select";
+
+import * as React from "react";
+
+import SelectWindow from "../../components/SelectWindow";
+
+const useStyles = makeStyles({
+  wrapper: {
+    display: "inline-flex",
+  },
+  formControl: {
+    margin: 8,
+    minWidth: 120,
+  },
+});
+
+function EditCloudCostControls({
+  windowOptions,
+  window,
+  setWindow,
+  aggregationOptions,
+  aggregateBy,
+  setAggregateBy,
+  costMetricOptions,
+  costMetric,
+  setCostMetric,
+  currencyOptions,
+  currency,
+  setCurrency,
+}) {
+  const classes = useStyles();
+  return (
+    <div className={classes.wrapper}>
+      <SelectWindow
+        windowOptions={windowOptions}
+        window={window}
+        setWindow={setWindow}
+      />
+      <FormControl className={classes.formControl}>
+        <InputLabel id="aggregation-select-label">Breakdown</InputLabel>
+        <Select
+          id="aggregation-select"
+          value={aggregateBy}
+          onChange={(e) => {
+            setAggregateBy(e.target.value);
+          }}
+        >
+          {aggregationOptions.map((opt) => (
+            <MenuItem key={opt.value} value={opt.value}>
+              {opt.name}
+            </MenuItem>
+          ))}
+        </Select>
+      </FormControl>
+      <FormControl className={classes.formControl}>
+        <InputLabel id="costMetric-label">Cost Metric</InputLabel>
+        <Select
+          id="costMetric"
+          value={costMetric}
+          onChange={(e) => setCostMetric(e.target.value)}
+        >
+          {costMetricOptions.map((opt) => (
+            <MenuItem key={opt.value} value={opt.value}>
+              {opt.name}
+            </MenuItem>
+          ))}
+        </Select>
+      </FormControl>
+      <FormControl className={classes.formControl}>
+        <InputLabel id="currency-label">Currency</InputLabel>
+        <Select
+          id="currency"
+          value={currency}
+          onChange={(e) => setCurrency(e.target.value)}
+        >
+          {currencyOptions?.map((currency) => (
+            <MenuItem key={currency} value={currency}>
+              {currency}
+            </MenuItem>
+          ))}
+        </Select>
+      </FormControl>
+    </div>
+  );
+}
+
+export default React.memo(EditCloudCostControls);

+ 49 - 0
ui/src/cloudCost/tokens.js

@@ -0,0 +1,49 @@
+const windowOptions = [
+  { name: "Today", value: "today" },
+  { name: "Yesterday", value: "yesterday" },
+  { name: "Last 24h", value: "24h" },
+  { name: "Last 48h", value: "48h" },
+  { name: "Week-to-date", value: "week" },
+  { name: "Last week", value: "lastweek" },
+  { name: "Last 7 days", value: "7d" },
+  { name: "Last 14 days", value: "14d" },
+];
+
+const aggregationOptions = [
+  { name: "Account", value: "accountID" },
+  { name: "Invoice Entity", value: "invoiceEntityID" },
+  { name: "Provider", value: "provider" },
+  { name: "Service ", value: "service" },
+  { name: "Category", value: "category" },
+  { name: "Item", value: "item" },
+];
+
+const costMetricOptions = [
+  { name: "Amortized Net Cost", value: "AmortizedNetCost" },
+  { name: "List Cost", value: "ListCost" },
+  { name: "Invoiced Cost", value: "InvoicedCost" },
+  { name: "Amortized Cost", value: "AmortizedCost" },
+];
+
+const aggMap = {
+  invoiceEntityID: "Invoice Entity",
+  provider: "Provider",
+  service: "Service",
+  accountID: "Account",
+};
+
+const costMetricToPropName = {
+  AmortizedNetCost: "amortizedNetCost",
+  AmortizedCost: "amortizedCost",
+  ListCost: "listCost",
+  NetCost: "netCost",
+  InvoicedCost: "invoicedCost",
+};
+
+export {
+  windowOptions,
+  aggregationOptions,
+  costMetricOptions,
+  aggMap,
+  costMetricToPropName,
+};

+ 305 - 0
ui/src/cloudCostReports.js

@@ -0,0 +1,305 @@
+import * as React from "react";
+import Page from "./components/Page";
+import Header from "./components/Header";
+import IconButton from "@material-ui/core/IconButton";
+import RefreshIcon from "@material-ui/icons/Refresh";
+import { makeStyles } from "@material-ui/styles";
+import { Paper, Typography } from "@material-ui/core";
+import CircularProgress from "@material-ui/core/CircularProgress";
+import { get, find } from "lodash";
+import { useLocation, useHistory } from "react-router";
+
+import { checkCustomWindow, toVerboseTimeRange } from "./util";
+import CloudCostEditControls from "./cloudCost/controls/cloudCostEditControls";
+import Subtitle from "./components/Subtitle";
+import Warnings from "./components/Warnings";
+import CloudCostTopService from "./services/cloudCostTop";
+
+import {
+  windowOptions,
+  costMetricOptions,
+  aggregationOptions,
+  aggMap,
+} from "./cloudCost/tokens";
+import { currencyCodes } from "./constants/currencyCodes";
+import CloudCost from "./cloudCost/cloudCost";
+import { CloudCostDetails } from "./cloudCost/cloudCostDetails";
+
+const CloudCostReports = () => {
+  const useStyles = makeStyles({
+    reportHeader: {
+      display: "flex",
+      flexFlow: "row",
+      padding: 24,
+    },
+    titles: {
+      flexGrow: 1,
+    },
+  });
+  const classes = useStyles();
+
+  // Form state, which controls form elements, but not the report itself. On
+  // certain actions, the form state may flow into the report state.
+  const [title, setTitle] = React.useState(
+    "Cumulative cost for last 7 days by account"
+  );
+  const [window, setWindow] = React.useState(windowOptions[0].value);
+  const [aggregateBy, setAggregateBy] = React.useState(
+    aggregationOptions[0].value
+  );
+  const [costMetric, setCostMetric] = React.useState(
+    costMetricOptions[0].value
+  );
+  const [filters, setFilters] = React.useState([]);
+  const [currency, setCurrency] = React.useState("USD");
+  const [selectedProviderId, setSelectedProviderId] = React.useState("");
+  const [selectedItemName, setselectedItemName] = React.useState("");
+  const sampleData = aggregateBy.includes("item");
+  // page and settings state
+  const [init, setInit] = React.useState(false);
+  const [fetch, setFetch] = React.useState(false);
+  const [loading, setLoading] = React.useState(true);
+  const [errors, setErrors] = React.useState([]);
+
+  // data
+  const [cloudCostData, setCloudCostData] = React.useState([]);
+
+  function generateTitle({ window, aggregateBy, costMetric }) {
+    let windowName = get(find(windowOptions, { value: window }), "name", "");
+    if (windowName === "") {
+      if (checkCustomWindow(window)) {
+        windowName = toVerboseTimeRange(window);
+      } else {
+        console.warn(`unknown window: ${window}`);
+      }
+    }
+
+    let aggregationName = get(
+      find(aggregationOptions, { value: aggregateBy }),
+      "name",
+      ""
+    ).toLowerCase();
+    if (aggregationName === "") {
+      console.warn(`unknown aggregation: ${aggregateBy}`);
+    }
+
+    let str = `Cumulative cost for ${windowName} by ${aggregationName}`;
+
+    if (!costMetric) {
+      str = `${str} amoritizedNetCost`;
+    }
+
+    return str;
+  }
+
+  // parse any context information from the URL
+  const routerLocation = useLocation();
+  const searchParams = new URLSearchParams(routerLocation.search);
+  const routerHistory = useHistory();
+
+  async function initialize() {
+    setInit(true);
+  }
+
+  async function fetchData() {
+    setLoading(true);
+    setErrors([]);
+    try {
+      const resp = await CloudCostTopService.fetchCloudCostData(
+        window,
+        aggregateBy,
+        costMetric,
+        filters
+      );
+      if (resp) {
+        setCloudCostData(resp);
+      } else {
+        if (resp.message && resp.message.indexOf("boundary error") >= 0) {
+          let match = resp.message.match(/(ETL is \d+\.\d+% complete)/);
+          let secondary = "Try again after ETL build is complete";
+          if (match.length > 0) {
+            secondary = `${match[1]}. ${secondary}`;
+          }
+          setErrors([
+            {
+              primary: "Data unavailable while ETL is building",
+              secondary: secondary,
+            },
+          ]);
+        }
+        setCloudCostData([]);
+      }
+    } catch (err) {
+      if (err.message.indexOf("404") === 0) {
+        setErrors([
+          {
+            primary: "Failed to load report data",
+            secondary:
+              "Please update Kubecost to the latest version, then contact support if problems persist.",
+          },
+        ]);
+      } else {
+        let secondary =
+          "Please contact Kubecost support with a bug report if problems persist.";
+        if (err.message.length > 0) {
+          secondary = err.message;
+        }
+        setErrors([
+          {
+            primary: "Failed to load report data",
+            secondary: secondary,
+          },
+        ]);
+      }
+      setCloudCostData([]);
+    }
+    setLoading(false);
+  }
+
+  function drilldown(row) {
+    if (aggregateBy.includes("item")) {
+      try {
+        setSelectedProviderId(row.providerID);
+        setselectedItemName(row.labelName ?? row.name);
+      } catch (e) {
+        logger.error(e);
+      }
+
+      return;
+    }
+    const nameParts = row.name.split("/");
+    const nextAgg = aggregateBy.includes("service") ? "item" : "service";
+    const aggToString = [aggregateBy];
+    const newFilters = aggToString.map((property, i) => {
+      const value = nameParts[i];
+      return {
+        property,
+        value,
+        name: aggMap[property] || property,
+      };
+    });
+    setFilters(newFilters);
+    setAggregateBy(nextAgg);
+  }
+
+  React.useEffect(() => {
+    setWindow(searchParams.get("window") || "7d");
+    setAggregateBy(searchParams.get("agg") || "provider");
+    setCostMetric(searchParams.get("costMetric") || "AmortizedNetCost");
+    setCurrency(searchParams.get("currency") || "USD");
+  }, [routerLocation]);
+
+  // Initialize once, then fetch report each time setFetch(true) is called
+  React.useEffect(() => {
+    if (!init) {
+      initialize();
+    }
+    if (init || fetch) {
+      fetchData();
+    }
+  }, [init, fetch]);
+
+  React.useEffect(() => {
+    setFetch(!fetch);
+    setTitle(generateTitle({ window, aggregateBy, costMetric }));
+  }, [window, aggregateBy, costMetric, filters]);
+
+  return (
+    <Page active="cloud.html">
+      <Header>
+        <IconButton aria-label="refresh" onClick={() => setFetch(true)}>
+          <RefreshIcon />
+        </IconButton>
+      </Header>
+
+      {!loading && errors.length > 0 && (
+        <div style={{ marginBottom: 20 }}>
+          <Warnings warnings={errors} />
+        </div>
+      )}
+
+      {init && (
+        <Paper id="cloud-cost">
+          <div className={classes.reportHeader}>
+            <div className={classes.titles}>
+              <Typography variant="h5">{title}</Typography>
+              <Subtitle report={{ window, aggregateBy }} />
+            </div>
+            <CloudCostEditControls
+              windowOptions={windowOptions}
+              window={window}
+              setWindow={(win) => {
+                searchParams.set("window", win);
+                routerHistory.push({
+                  search: `?${searchParams.toString()}`,
+                });
+              }}
+              aggregationOptions={aggregationOptions}
+              aggregateBy={aggregateBy}
+              setAggregateBy={(agg) => {
+                searchParams.set("agg", agg);
+                routerHistory.push({
+                  search: `?${searchParams.toString()}`,
+                });
+              }}
+              costMetricOptions={costMetricOptions}
+              costMetric={costMetric}
+              setCostMetric={(c) => {
+                searchParams.set("costMetric", c);
+                routerHistory.push({
+                  search: `?${searchParams.toString()}`,
+                });
+              }}
+              title={title}
+              // cumulativeData={cumulativeData}
+              currency={currency}
+              currencyOptions={currencyCodes}
+              setCurrency={(curr) => {
+                searchParams.set("currency", curr);
+                routerHistory.push({
+                  search: `?${searchParams.toString()}`,
+                });
+              }}
+            />
+          </div>
+
+          {loading && (
+            <div style={{ display: "flex", justifyContent: "center" }}>
+              <div style={{ paddingTop: 100, paddingBottom: 100 }}>
+                <CircularProgress />
+              </div>
+            </div>
+          )}
+
+          {!loading && (
+            <CloudCost
+              cumulativeData={cloudCostData.tableRows}
+              currency={currency}
+              graphData={cloudCostData.graphData}
+              totalData={cloudCostData.tableTotal}
+              drilldown={drilldown}
+              sampleData={sampleData}
+            />
+          )}
+          {selectedProviderId && selectedItemName && (
+            <CloudCostDetails
+              onClose={() => {
+                setSelectedProviderId("");
+                setselectedItemName("");
+              }}
+              selectedProviderId={selectedProviderId}
+              selectedItem={selectedItemName}
+              agg={aggregateBy}
+              filters={filters}
+              costMetric={costMetric}
+              window={window}
+              currency={currency}
+            />
+          )}
+        </Paper>
+      )}
+    </Page>
+  );
+};
+
+export default React.memo(CloudCostReports);

+ 0 - 200
ui/src/components/AllocationReport.js

@@ -1,200 +0,0 @@
-import React, { useEffect, useState } from 'react'
-import { get, round } from 'lodash'
-import { makeStyles } from '@material-ui/styles'
-import Table from '@material-ui/core/Table'
-import TableBody from '@material-ui/core/TableBody'
-import TableCell from '@material-ui/core/TableCell'
-import TableContainer from '@material-ui/core/TableContainer'
-import TableHead from '@material-ui/core/TableHead'
-import TablePagination from '@material-ui/core/TablePagination'
-import TableRow from '@material-ui/core/TableRow'
-import TableSortLabel from '@material-ui/core/TableSortLabel'
-import Typography from '@material-ui/core/Typography'
-import AllocationChart from './AllocationChart';
-import { toCurrency } from '../util';
-
-const useStyles = makeStyles({
-  noResults: {
-    padding: 24,
-  },
-})
-
-function descendingComparator(a, b, orderBy) {
-  if (get(b, orderBy) < get(a, orderBy)) {
-    return -1
-  }
-  if (get(b, orderBy) > get(a, orderBy)) {
-    return 1
-  }
-  return 0
-}
-
-function getComparator(order, orderBy) {
-  return order === 'desc'
-    ? (a, b) => descendingComparator(a, b, orderBy)
-    : (a, b) => -descendingComparator(a, b, orderBy)
-}
-
-function stableSort(array, comparator) {
-  const stabilizedThis = array.map((el, index) => [el, index])
-  stabilizedThis.sort((a, b) => {
-    const order = comparator(a[0], b[0])
-    if (order !== 0) return order
-    return a[1] - b[1]
-  })
-  return stabilizedThis.map((el) => el[0])
-}
-
-const headCells = [
-  { id: 'name', numeric: false, label: 'Name', width: 'auto' },
-  { id: 'cpuCost', numeric: true, label: 'CPU', width: 90 },
-  { id: 'ramCost', numeric: true, label: "RAM", width: 90 },
-  { id: 'pvCost', numeric: true, label: 'PV', width: 90 },
-  { id: 'totalEfficiency', numeric: true, label: 'Efficiency', width: 90 },
-  { id: 'totalCost', numeric: true, label: 'Total cost', width: 90 },
-]
-
-const AllocationReport = ({ allocationData, cumulativeData, totalData, currency }) => {
-  const classes = useStyles()
-
-  if (allocationData.length === 0) {
-    return <Typography variant="body2" className={classes.noResults}>No results</Typography>
-  }
-
-  const [order, setOrder] = React.useState('desc')
-  const [orderBy, setOrderBy] = React.useState('totalCost')
-  const [page, setPage] = useState(0)
-  const [rowsPerPage, setRowsPerPage] = useState(25)
-  const numData = cumulativeData.length
-
-  useEffect(() => {
-    setPage(0)
-  }, [numData])
-
-  const lastPage = Math.floor(numData / rowsPerPage)
-
-  const handleChangePage = (event, newPage) => setPage(newPage)
-
-  const handleChangeRowsPerPage = event => {
-    setRowsPerPage(parseInt(event.target.value, 10))
-    setPage(0)
-  }
-
-  const createSortHandler = (property) => (event) => handleRequestSort(event, property)
-
-  const handleRequestSort = (event, property) => {
-    const isDesc = orderBy === property && order === 'desc'
-    setOrder(isDesc ? 'asc' : 'desc')
-    setOrderBy(property)
-  }
-
-  const orderedRows = stableSort(cumulativeData, getComparator(order, orderBy))
-  const pageRows = orderedRows.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
-
-  return (
-    <div id="report">
-      <AllocationChart allocationRange={allocationData} currency={currency} n={10} height={300} />
-      <TableContainer>
-        <Table>
-          <TableHead>
-            <TableRow>
-              {headCells.map((cell) => (
-                <TableCell
-                  key={cell.id}
-                  colSpan={cell.colspan}
-                  align={cell.numeric ? 'right' : 'left'}
-                  sortDirection={orderBy === cell.id ? order : false}
-                  style={{ width: cell.width }}
-                >
-                  <TableSortLabel
-                    active={orderBy === cell.id}
-                    direction={orderBy === cell.id ? order : 'asc'}
-                    onClick={createSortHandler(cell.id)}
-                  >
-                    {cell.label}
-                  </TableSortLabel>
-                </TableCell>
-              ))}
-            </TableRow>
-          </TableHead>
-          <TableBody>
-            <TableRow>
-              {headCells.map((cell) => {
-                return (
-                <TableCell
-                  key={cell.id}
-                  colSpan={cell.colspan}
-                  align={cell.numeric ? 'right' : 'left'}
-                  style={{ fontWeight: 500 }}
-                >
-                  {cell.numeric
-                    ? (cell.label === 'Efficiency'
-                      ? (totalData.totalEfficiency == 1.0 && totalData.cpuReqCoreHrs == 0 && totalData.ramReqByteHrs == 0)
-                        ? "Inf%"
-                        : `${round(totalData.totalEfficiency*100, 1)}%`
-                      : toCurrency(totalData[cell.id], currency))
-                    : totalData[cell.id]}
-                </TableCell>
-              )})}
-            </TableRow>
-            {pageRows.map((row, key) => {
-              if (row.name === "__unmounted__") {
-                row.name = "Unmounted PVs"
-              }
-
-              let isIdle = row.name.indexOf("__idle__") >= 0
-              let isUnallocated = row.name.indexOf("__unallocated__") >= 0
-              let isUnmounted = row.name.indexOf("Unmounted PVs") >= 0
-
-              // Replace "efficiency" with Inf if there is usage w/o request
-              let efficiency = round(row.totalEfficiency*100, 1)
-              if (row.totalEfficiency == 1.0 && row.cpuReqCoreHrs == 0 && row.ramReqByteHrs == 0) {
-                efficiency = "Inf"
-              }
-
-              // Do not allow drill-down for idle and unallocated rows
-              if (isIdle || isUnallocated || isUnmounted) {
-                return (
-                  <TableRow key={key}>
-                    <TableCell align="left">{row.name}</TableCell>
-                    <TableCell align="right">{toCurrency(row.cpuCost, currency)}</TableCell>
-                    <TableCell align="right">{toCurrency(row.ramCost, currency)}</TableCell>
-                    <TableCell align="right">{toCurrency(row.pvCost, currency)}</TableCell>
-                    {isIdle ? (
-                      <TableCell align="right">&mdash;</TableCell>
-                    ) : (
-                      <TableCell align="right">{efficiency}%</TableCell>
-                    )}
-                    <TableCell align="right">{toCurrency(row.totalCost, currency)}</TableCell>
-                  </TableRow>
-                )
-              }
-
-              return (
-                <TableRow key={key}>
-                  <TableCell align="left">{row.name}</TableCell>
-                  <TableCell align="right">{toCurrency(row.cpuCost, currency)}</TableCell>
-                  <TableCell align="right">{toCurrency(row.ramCost, currency)}</TableCell>
-                  <TableCell align="right">{toCurrency(row.pvCost, currency)}</TableCell>
-                  <TableCell align="right">{efficiency}%</TableCell>
-                  <TableCell align="right">{toCurrency(row.totalCost, currency)}</TableCell>
-                </TableRow>
-              )
-            })}
-          </TableBody>
-        </Table>
-      </TableContainer>
-      <TablePagination
-        component="div"
-        count={numData}
-        rowsPerPage={rowsPerPage}
-        rowsPerPageOptions={[10, 25, 50]}
-        page={Math.min(page, lastPage)}
-        onChangePage={handleChangePage}
-        onChangeRowsPerPage={handleChangeRowsPerPage}
-      />
-    </div>
-  )
-}
-
-export default React.memo(AllocationReport)

+ 37 - 25
ui/src/components/Header.js

@@ -1,48 +1,60 @@
-import React from 'react'
-import { makeStyles } from '@material-ui/styles'
-import Breadcrumbs from '@material-ui/core/Breadcrumbs';
-import Link from '@material-ui/core/Link';
-import Typography from '@material-ui/core/Typography';
+import * as React from "react";
+import { makeStyles } from "@material-ui/styles";
+import Breadcrumbs from "@material-ui/core/Breadcrumbs";
+import Link from "@material-ui/core/Link";
+import Typography from "@material-ui/core/Typography";
+import { useLocation } from "react-router-dom";
 
 const useStyles = makeStyles({
   root: {
-    alignItems: 'center',
-    display: 'flex',
-    flexFlow: 'row',
-    marginBottom: 20,
-    width: '100%',
+    alignItems: "center",
+    display: "flex",
+    flexFlow: "row",
+    width: "100%",
+    marginTop: "10px",
   },
   context: {
-    flex: '1 0 auto',
+    flex: "1 0 auto",
   },
   actions: {
-    flex: '0 0 auto',
+    flex: "0 0 auto",
   },
 });
 
 const Header = (props) => {
-  const classes = useStyles()
-  const { title, breadcrumbs } = props
+  const classes = useStyles();
+  const { title, breadcrumbs } = props;
+  const { pathname } = useLocation();
+
+  const headerTitle = pathname === "/cloud" ? "Cloud Costs" : "Cost Allocation";
 
   return (
     <div className={classes.root}>
-      <img src={ require('../images/logo.png') } alt="OpenCost" />
+      <Typography variant="h3" style={{ marginBottom: "10px" }}>
+        {headerTitle}
+      </Typography>
       <div className={classes.context}>
-        {title && <Typography variant="h4" className={classes.title}>{props.title}</Typography>}
+        {title && (
+          <Typography variant="h4" className={classes.title}>
+            {props.title}
+          </Typography>
+        )}
         {breadcrumbs && breadcrumbs.length > 0 && (
           <Breadcrumbs aria-label="breadcrumb">
-            {breadcrumbs.slice(0, breadcrumbs.length-1).map(b => (
-              <Link color="inherit" href={b.href} key={b.name}>{b.name}</Link>
+            {breadcrumbs.slice(0, breadcrumbs.length - 1).map((b) => (
+              <Link color="inherit" href={b.href} key={b.name}>
+                {b.name}
+              </Link>
             ))}
-            <Typography color="textPrimary">{breadcrumbs[breadcrumbs.length-1].name}</Typography>
+            <Typography color="textPrimary">
+              {breadcrumbs[breadcrumbs.length - 1].name}
+            </Typography>
           </Breadcrumbs>
         )}
       </div>
-      <div className={classes.actions}>
-        {props.children}
-      </div>
+      <div className={classes.actions}>{props.children}</div>
     </div>
-  )
-}
+  );
+};
 
-export default Header
+export default Header;

+ 78 - 0
ui/src/components/Nav/NavItem.js

@@ -0,0 +1,78 @@
+import * as React from "react";
+import { ListItem, ListItemIcon, ListItemText } from "@material-ui/core";
+import { Link } from "react-router-dom";
+import { makeStyles } from "@material-ui/styles";
+
+const NavItem = ({ active, href, name, onClick, secondary, title, icon }) => {
+  const useStyles = makeStyles({
+    root: {
+      cursor: "pointer",
+      "&:hover": {
+        backgroundColor: "#ebebeb",
+      },
+      "&:selected": {
+        backgroundColor: "#e1e1e1",
+      },
+    },
+    text: {
+      maxWidth: 200,
+      overflow: "hidden",
+      textOverflow: "ellipsis",
+      whiteSpace: "nowrap",
+    },
+    activeIcon: {
+      color: "#346ef2",
+      minWidth: 36,
+    },
+    activeText: {
+      color: "#346ef2",
+    },
+    icon: {
+      color: "#4e4e4e",
+      minWidth: 36,
+    },
+  });
+  const classes = useStyles();
+
+  const listItemIconClasses = { root: classes.icon };
+  const listItemTextClasses = {
+    secondary: classes.text,
+  };
+
+  if (active) {
+    listItemIconClasses.root = classes.activeIcon;
+    listItemTextClasses.primary = classes.activeText;
+  }
+
+  const renderListItemCore = () => (
+    <ListItem
+      className={active ? "active" : ""}
+      classes={{ root: classes.root }}
+      onClick={(e) => {
+        if (onClick) {
+          onClick();
+          e.stopPropagation();
+        }
+      }}
+      selected={active}
+      title={title}
+    >
+      <ListItemIcon classes={listItemIconClasses}>{icon}</ListItemIcon>
+      <ListItemText
+        classes={listItemTextClasses}
+        primary={name}
+        secondary={secondary}
+      />
+    </ListItem>
+  );
+
+  return href && !active ? (
+    <Link style={{ textDecoration: "none", color: "inherit" }} to={`${href}`}>
+      {renderListItemCore()}
+    </Link>
+  ) : (
+    renderListItemCore()
+  );
+};
+
+export { NavItem };

+ 70 - 0
ui/src/components/Nav/SidebarNav.js

@@ -0,0 +1,70 @@
+import * as React from "react";
+import { Drawer, List } from "@material-ui/core";
+
+import { NavItem } from "./NavItem";
+import { BarChart } from "@material-ui/icons";
+import { Cloud } from "@material-ui/icons";
+import { makeStyles } from "@material-ui/styles";
+
+const DRAWER_WIDTH = 200;
+
+const SidebarNav = ({ active }) => {
+  const useStyles = makeStyles({
+    drawer: {
+      width: DRAWER_WIDTH,
+      flexShrink: 0,
+    },
+    drawerPaper: {
+      backgroundColor: "inherit",
+      border: 0,
+      width: DRAWER_WIDTH,
+      paddingTop: "2.5rem",
+    },
+    text: {
+      overflow: "hidden",
+      textOverflow: "ellipsis",
+      whiteSpace: "nowrap",
+    },
+  });
+
+  const classes = useStyles();
+
+  const [init, setInit] = React.useState(false);
+
+  React.useEffect(() => {
+    if (!init) {
+      setInit(true);
+    }
+  }, [init]);
+
+  const top = [
+    {
+      name: "Cost Allocation",
+      href: "allocation",
+      icon: <BarChart />,
+    },
+    { name: "Cloud Costs", href: "cloud", icon: <Cloud /> },
+  ];
+
+  return (
+    <Drawer
+      anchor={"left"}
+      className={classes.drawer}
+      classes={{ paper: classes.drawerPaper }}
+      variant={"permanent"}
+    >
+      <img
+        src={require("../../images/logo.png")}
+        alt="OpenCost"
+        style={{ flexShrink: 1, padding: "1rem" }}
+      />
+      <List style={{ flexGrow: 1 }}>
+        {top.map((l) => (
+          <NavItem active={active === `/${l.href}`} key={l.name} {...l} />
+        ))}
+      </List>
+    </Drawer>
+  );
+};
+
+export { SidebarNav };

+ 3 - 0
ui/src/components/Nav/index.js

@@ -0,0 +1,3 @@
+import { SidebarNav } from "./SidebarNav";
+
+export default SidebarNav;

+ 32 - 19
ui/src/components/Page.js

@@ -1,33 +1,46 @@
-import { makeStyles } from '@material-ui/styles'
-import React from 'react'
+import { makeStyles } from "@material-ui/styles";
+import * as React from "react";
+import { useLocation } from "react-router-dom";
+import { SidebarNav } from "./Nav/SidebarNav";
 
 const useStyles = makeStyles({
   wrapper: {
-    display: 'flex',
-    flexFlow: 'column',
+    position: "relative",
+    height: "100vh",
     flexGrow: 1,
-    margin: '20px 30px 0 30px',
-    minWidth: 800,
+    overflowX: "auto",
+    paddingLeft: "2rem",
+    paddingRight: "rem",
+    paddingTop: "2.5rem",
   },
   flexGrow: {
-    display: 'flex',
-    flexFlow: 'column',
+    display: "flex",
+    flexFlow: "column",
     flexGrow: 1,
-  }
-})
+  },
+  body: {
+    display: "flex",
+    overflowY: "scroll",
+    margin: "0px",
+    backgroundColor: "f3f3f3",
+  },
+});
+
+const Page = (props) => {
+  const classes = useStyles();
 
-const Page = props => {
-  const classes = useStyles()
+  const { pathname } = useLocation();
 
   return (
-    <div className={classes.flexGrow}>
-      <div className={classes.wrapper}>
-        <div className={classes.flexGrow}>
-          {props.children}
+    <div className={classes.body}>
+      <SidebarNav active={pathname} />
+      <div className={classes.flexGrow}>
+        <div className={classes.wrapper}>
+          <div className={classes.flexGrow}>{props.children}</div>
         </div>
       </div>
     </div>
-  )
-}
+  );
+};
 
-export default Page
+export default Page;

+ 19 - 18
ui/src/components/Subtitle.js

@@ -1,43 +1,44 @@
-import React from 'react'
-import { makeStyles } from '@material-ui/styles'
-import { isArray, upperFirst } from 'lodash'
-import Breadcrumbs from '@material-ui/core/Breadcrumbs'
-import Link from '@material-ui/core/Link'
-import NavigateNextIcon from '@material-ui/icons/NavigateNext'
-import Tooltip from '@material-ui/core/Tooltip'
-import Typography from '@material-ui/core/Typography'
-import { toVerboseTimeRange } from '../util';
+import * as React from "react";
+import { makeStyles } from "@material-ui/styles";
+import { upperFirst } from "lodash";
+import Breadcrumbs from "@material-ui/core/Breadcrumbs";
+import NavigateNextIcon from "@material-ui/icons/NavigateNext";
+import Typography from "@material-ui/core/Typography";
+import { toVerboseTimeRange } from "../util";
 
 const useStyles = makeStyles({
   root: {
-    '& > * + *': {
+    "& > * + *": {
       marginTop: 2,
     },
   },
   link: {
     cursor: "pointer",
   },
-})
+});
 
-const Subtitle = ({ report }) => {
-  const classes = useStyles()
+const Subtitle = ({ report, onClick }) => {
+  const classes = useStyles();
 
-  const { aggregateBy, window } = report
+  const { aggregateBy, window } = report;
 
   return (
     <div className={classes.root}>
       <Breadcrumbs
         separator={<NavigateNextIcon fontSize="small" />}
         aria-label="breadcrumb"
+        onClick={onClick}
       >
         {aggregateBy && aggregateBy.length > 0 ? (
-          <Typography>{toVerboseTimeRange(window)} by {upperFirst(aggregateBy)}</Typography>
+          <Typography>
+            {toVerboseTimeRange(window)} by {upperFirst(aggregateBy)}
+          </Typography>
         ) : (
           <Typography>{toVerboseTimeRange(window)}</Typography>
         )}
       </Breadcrumbs>
     </div>
-  )
-}
+  );
+};
 
-export default React.memo(Subtitle)
+export default React.memo(Subtitle);

+ 241 - 0
ui/src/components/allocationReport.js

@@ -0,0 +1,241 @@
+import React, { useEffect, useState } from "react";
+import { get, round } from "lodash";
+import { makeStyles } from "@material-ui/styles";
+import Table from "@material-ui/core/Table";
+import TableBody from "@material-ui/core/TableBody";
+import TableCell from "@material-ui/core/TableCell";
+import TableContainer from "@material-ui/core/TableContainer";
+import TableHead from "@material-ui/core/TableHead";
+import TablePagination from "@material-ui/core/TablePagination";
+import TableRow from "@material-ui/core/TableRow";
+import TableSortLabel from "@material-ui/core/TableSortLabel";
+import Typography from "@material-ui/core/Typography";
+import AllocationChart from "./AllocationChart";
+import { toCurrency } from "../util";
+
+const useStyles = makeStyles({
+  noResults: {
+    padding: 24,
+  },
+});
+
+function descendingComparator(a, b, orderBy) {
+  if (get(b, orderBy) < get(a, orderBy)) {
+    return -1;
+  }
+  if (get(b, orderBy) > get(a, orderBy)) {
+    return 1;
+  }
+  return 0;
+}
+
+function getComparator(order, orderBy) {
+  return order === "desc"
+    ? (a, b) => descendingComparator(a, b, orderBy)
+    : (a, b) => -descendingComparator(a, b, orderBy);
+}
+
+function stableSort(array, comparator) {
+  const stabilizedThis = array.map((el, index) => [el, index]);
+  stabilizedThis.sort((a, b) => {
+    const order = comparator(a[0], b[0]);
+    if (order !== 0) return order;
+    return a[1] - b[1];
+  });
+  return stabilizedThis.map((el) => el[0]);
+}
+
+const headCells = [
+  { id: "name", numeric: false, label: "Name", width: "auto" },
+  { id: "cpuCost", numeric: true, label: "CPU", width: 90 },
+  { id: "ramCost", numeric: true, label: "RAM", width: 90 },
+  { id: "pvCost", numeric: true, label: "PV", width: 90 },
+  { id: "totalEfficiency", numeric: true, label: "Efficiency", width: 90 },
+  { id: "totalCost", numeric: true, label: "Total cost", width: 90 },
+];
+
+const AllocationReport = ({
+  allocationData,
+  cumulativeData,
+  totalData,
+  currency,
+}) => {
+  const classes = useStyles();
+
+  if (allocationData.length === 0) {
+    return (
+      <Typography variant="body2" className={classes.noResults}>
+        No results
+      </Typography>
+    );
+  }
+
+  const [order, setOrder] = React.useState("desc");
+  const [orderBy, setOrderBy] = React.useState("totalCost");
+  const [page, setPage] = useState(0);
+  const [rowsPerPage, setRowsPerPage] = useState(25);
+  const numData = cumulativeData.length;
+
+  useEffect(() => {
+    setPage(0);
+  }, [numData]);
+
+  const lastPage = Math.floor(numData / rowsPerPage);
+
+  const handleChangePage = (event, newPage) => setPage(newPage);
+
+  const handleChangeRowsPerPage = (event) => {
+    setRowsPerPage(parseInt(event.target.value, 10));
+    setPage(0);
+  };
+
+  const createSortHandler = (property) => (event) =>
+    handleRequestSort(event, property);
+
+  const handleRequestSort = (event, property) => {
+    const isDesc = orderBy === property && order === "desc";
+    setOrder(isDesc ? "asc" : "desc");
+    setOrderBy(property);
+  };
+
+  const orderedRows = stableSort(cumulativeData, getComparator(order, orderBy));
+  const pageRows = orderedRows.slice(
+    page * rowsPerPage,
+    page * rowsPerPage + rowsPerPage
+  );
+
+  return (
+    <div id="report">
+      <AllocationChart
+        allocationRange={allocationData}
+        currency={currency}
+        n={10}
+        height={300}
+      />
+      <TableContainer>
+        <Table>
+          <TableHead>
+            <TableRow>
+              {headCells.map((cell) => (
+                <TableCell
+                  key={cell.id}
+                  colSpan={cell.colspan}
+                  align={cell.numeric ? "right" : "left"}
+                  sortDirection={orderBy === cell.id ? order : false}
+                  style={{ width: cell.width }}
+                >
+                  <TableSortLabel
+                    active={orderBy === cell.id}
+                    direction={orderBy === cell.id ? order : "asc"}
+                    onClick={createSortHandler(cell.id)}
+                  >
+                    {cell.label}
+                  </TableSortLabel>
+                </TableCell>
+              ))}
+            </TableRow>
+          </TableHead>
+          <TableBody>
+            <TableRow>
+              {headCells.map((cell) => {
+                return (
+                  <TableCell
+                    key={cell.id}
+                    colSpan={cell.colspan}
+                    align={cell.numeric ? "right" : "left"}
+                    style={{ fontWeight: 500 }}
+                  >
+                    {cell.numeric
+                      ? cell.label === "Efficiency"
+                        ? totalData.totalEfficiency == 1.0 &&
+                          totalData.cpuReqCoreHrs == 0 &&
+                          totalData.ramReqByteHrs == 0
+                          ? "Inf%"
+                          : `${round(totalData.totalEfficiency * 100, 1)}%`
+                        : toCurrency(totalData[cell.id], currency)
+                      : totalData[cell.id]}
+                  </TableCell>
+                );
+              })}
+            </TableRow>
+            {pageRows.map((row, key) => {
+              if (row.name === "__unmounted__") {
+                row.name = "Unmounted PVs";
+              }
+
+              let isIdle = row.name.indexOf("__idle__") >= 0;
+              let isUnallocated = row.name.indexOf("__unallocated__") >= 0;
+              let isUnmounted = row.name.indexOf("Unmounted PVs") >= 0;
+
+              // Replace "efficiency" with Inf if there is usage w/o request
+              let efficiency = round(row.totalEfficiency * 100, 1);
+              if (
+                row.totalEfficiency == 1.0 &&
+                row.cpuReqCoreHrs == 0 &&
+                row.ramReqByteHrs == 0
+              ) {
+                efficiency = "Inf";
+              }
+
+              // Do not allow drill-down for idle and unallocated rows
+              if (isIdle || isUnallocated || isUnmounted) {
+                return (
+                  <TableRow key={key}>
+                    <TableCell align="left">{row.name}</TableCell>
+                    <TableCell align="right">
+                      {toCurrency(row.cpuCost, currency)}
+                    </TableCell>
+                    <TableCell align="right">
+                      {toCurrency(row.ramCost, currency)}
+                    </TableCell>
+                    <TableCell align="right">
+                      {toCurrency(row.pvCost, currency)}
+                    </TableCell>
+                    {isIdle ? (
+                      <TableCell align="right">&mdash;</TableCell>
+                    ) : (
+                      <TableCell align="right">{efficiency}%</TableCell>
+                    )}
+                    <TableCell align="right">
+                      {toCurrency(row.totalCost, currency)}
+                    </TableCell>
+                  </TableRow>
+                );
+              }
+
+              return (
+                <TableRow key={key}>
+                  <TableCell align="left">{row.name}</TableCell>
+                  <TableCell align="right">
+                    {toCurrency(row.cpuCost, currency)}
+                  </TableCell>
+                  <TableCell align="right">
+                    {toCurrency(row.ramCost, currency)}
+                  </TableCell>
+                  <TableCell align="right">
+                    {toCurrency(row.pvCost, currency)}
+                  </TableCell>
+                  <TableCell align="right">{efficiency}%</TableCell>
+                  <TableCell align="right">
+                    {toCurrency(row.totalCost, currency)}
+                  </TableCell>
+                </TableRow>
+              );
+            })}
+          </TableBody>
+        </Table>
+      </TableContainer>
+      <TablePagination
+        component="div"
+        count={numData}
+        rowsPerPage={rowsPerPage}
+        rowsPerPageOptions={[10, 25, 50]}
+        page={Math.min(page, lastPage)}
+        onChangePage={handleChangePage}
+        onChangeRowsPerPage={handleChangeRowsPerPage}
+      />
+    </div>
+  );
+};
+
+export default React.memo(AllocationReport);

+ 14 - 11
ui/src/index.html

@@ -1,13 +1,16 @@
 <!DOCTYPE html>
 <html>
-	<head>
-		<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
-		<meta content="utf-8" http-equiv="encoding" />
-		<link rel="icon" href="./images/favicon.ico" />
-		<link rel="stylesheet" href="./css/index.css" />
-	</head>
-	<body>
-		<div id="app" class="page-container"></div>
-		<script src="./app.js" type="module"></script>
-	</body>
-</html>
+
+<head>
+	<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
+	<meta content="utf-8" http-equiv="encoding" />
+	<link rel="icon" href="./images/favicon.ico" />
+	<link rel="stylesheet" href="./css/index.css" />
+</head>
+
+<body>
+	<div id="app" class="page-container"></div>
+	<script src="./app.js" type="module"></script>
+</body>
+
+</html>

BIN
ui/src/opencost-ui.png


+ 25 - 0
ui/src/route.js

@@ -0,0 +1,25 @@
+import * as React from "react";
+import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
+
+import Reports from "./Reports.js";
+import CloudCostReports from "./cloudCostReports.js";
+
+const Routes = () => {
+  return (
+    <Router>
+      <Switch>
+        <Route exact path="/">
+          <Reports />
+        </Route>
+        <Route exact path="/allocation">
+          <Reports />
+        </Route>
+        <Route exact path="/cloud">
+          <CloudCostReports />
+        </Route>
+      </Switch>
+    </Router>
+  );
+};
+
+export default Routes;

+ 11 - 9
ui/src/services/allocation.js

@@ -1,23 +1,25 @@
-import axios from 'axios';
+import axios from "axios";
 
 class AllocationService {
-  BASE_URL = process.env.BASE_URL || '{PLACEHOLDER_BASE_URL}';
+  BASE_URL = process.env.BASE_URL || "{PLACEHOLDER_BASE_URL}";
 
   async fetchAllocation(win, aggregate, options) {
-    if (this.BASE_URL.includes('PLACEHOLDER_BASE_URL')) {
-      this.BASE_URL = `http://localhost:9090/model`
+    if (this.BASE_URL.includes("PLACEHOLDER_BASE_URL")) {
+      this.BASE_URL = `http://localhost:9090/model`;
     }
-    
-    const { accumulate, filters, } = options;
+
+    const { accumulate, filters } = options;
     const params = {
       window: win,
       aggregate: aggregate,
-      step: '1d',
+      step: "1d",
     };
-    if (typeof accumulate === 'boolean') {
+    if (typeof accumulate === "boolean") {
       params.accumulate = accumulate;
     }
-    const result = await axios.get(`${this.BASE_URL}/allocation/compute`, { params });
+    const result = await axios.get(`${this.BASE_URL}/allocation/compute`, {
+      params,
+    });
     return result.data;
   }
 }

+ 43 - 0
ui/src/services/cloudCostDayTotals.js

@@ -0,0 +1,43 @@
+import axios from "axios";
+import { getCloudFilters } from "../util";
+import { costMetricToPropName } from "../cloudCost/tokens";
+
+function formatItemsForCost({ data, costType }) {
+  return data.sets.map(({ cloudCosts, window }) => {
+    return {
+      date: window.start,
+      cost: Object.values(cloudCosts).reduce(
+        (acc, costs) => acc + costs[costType || "amortizedNetCost"].cost,
+        0
+      ),
+    };
+  });
+}
+
+class CloudCostDayTotalsService {
+  BASE_URL = process.env.BASE_URL || "{PLACEHOLDER_BASE_URL}";
+
+  async fetchCloudCostData(window, aggregate, costMetric, filters) {
+    if (this.BASE_URL.includes("PLACEHOLDER_BASE_URL")) {
+      this.BASE_URL = `http://localhost:9090/model`;
+    }
+
+    if (aggregate.includes("item")) {
+      const resp = await axios.get(
+        `${
+          this.BASE_URL
+        }/cloudCost?window=${window}&costMetric=${costMetric}${getCloudFilters(
+          filters
+        )}`
+      );
+      const costMetricProp = costMetricToPropName[costMetric];
+
+      const result_2 = await resp.data;
+      return { data: formatItemsForCost(result_2, costMetricProp) };
+    }
+
+    return [];
+  }
+}
+
+export default new CloudCostDayTotalsService();

+ 54 - 0
ui/src/services/cloudCostTop.js

@@ -0,0 +1,54 @@
+import axios from "axios";
+import { getCloudFilters, formatSampleItemsForGraph } from "../util";
+
+class CloudCostTopService {
+  BASE_URL = process.env.BASE_URL || "{PLACEHOLDER_BASE_URL}";
+
+  async fetchCloudCostData(window, aggregate, costMetric, filters) {
+    if (this.BASE_URL.includes("PLACEHOLDER_BASE_URL")) {
+      this.BASE_URL = `http://localhost:9090/model`;
+    }
+
+    const params = {
+      window,
+      aggregate,
+      costMetric,
+      filters,
+      limit: 1000,
+    };
+
+    if (aggregate.includes("item")) {
+      const resp = await axios.get(
+        `${
+          this.BASE_URL
+        }/cloudCost?window=${window}&costMetric=${costMetric}${getCloudFilters(
+          filters
+        )}`
+      );
+      const result_2 = await resp.data;
+
+      return formatSampleItemsForGraph(result_2, costMetric);
+    }
+
+    const tableView = await axios.get(`${this.BASE_URL}/cloudCost/view/table`, {
+      params,
+    });
+    const totalsView = await axios.get(
+      `${this.BASE_URL}/cloudCost/view/totals`,
+      {
+        params,
+      }
+    );
+    const graphView = await axios.get(`${this.BASE_URL}/cloudCost/view/graph`, {
+      params,
+    });
+
+    return {
+      tableRows: tableView.data.data,
+      graphData: graphView.data.data,
+      tableTotal: totalsView.data.data.combined,
+    };
+  }
+}
+
+export default new CloudCostTopService();

BIN
ui/src/thumbnail.png


+ 322 - 157
ui/src/util.js

@@ -1,102 +1,109 @@
-import { forEach, get, round } from 'lodash'
+import { forEach, get, round } from "lodash";
+import { costMetricToPropName } from "./cloudCost/tokens";
 
 // rangeToCumulative takes an AllocationSetRange (type: array[AllocationSet])
 // and accumulates the values into a single AllocationSet (type: object)
 export function rangeToCumulative(allocationSetRange, aggregateBy) {
   if (allocationSetRange.length === 0) {
-    return null
+    return null;
   }
 
-  const result = {}
+  const result = {};
 
   forEach(allocationSetRange, (allocSet) => {
     forEach(allocSet, (alloc) => {
       if (result[alloc.name] === undefined) {
-        const hrs = get(alloc, 'minutes', 0) / 60.0
+        const hrs = get(alloc, "minutes", 0) / 60.0;
 
         result[alloc.name] = {
           name: alloc.name,
           [aggregateBy]: alloc.name,
-          cpuCost: get(alloc, 'cpuCost', 0),
-          gpuCost: get(alloc, 'gpuCost', 0),
-          ramCost: get(alloc, 'ramCost', 0),
-          pvCost: get(alloc, 'pvCost', 0),
-          networkCost: get(alloc, 'networkCost', 0),
-          sharedCost: get(alloc, 'sharedCost', 0),
-          externalCost: get(alloc, 'externalCost', 0),
-          totalCost: get(alloc, 'totalCost', 0),
-          cpuUseCoreHrs: get(alloc, 'cpuCoreUsageAverage', 0) * hrs,
-          cpuReqCoreHrs: get(alloc, 'cpuCoreRequestAverage', 0) * hrs,
-          ramUseByteHrs: get(alloc, 'ramByteUsageAverage', 0) * hrs,
-          ramReqByteHrs: get(alloc, 'ramByteRequestAverage', 0) * hrs,
-          cpuEfficiency: get(alloc, 'cpuEfficiency', 0),
-          ramEfficiency: get(alloc, 'ramEfficiency', 0),
-          totalEfficiency: get(alloc, 'totalEfficiency', 0),
-        }
+          cpuCost: get(alloc, "cpuCost", 0),
+          gpuCost: get(alloc, "gpuCost", 0),
+          ramCost: get(alloc, "ramCost", 0),
+          pvCost: get(alloc, "pvCost", 0),
+          networkCost: get(alloc, "networkCost", 0),
+          sharedCost: get(alloc, "sharedCost", 0),
+          externalCost: get(alloc, "externalCost", 0),
+          totalCost: get(alloc, "totalCost", 0),
+          cpuUseCoreHrs: get(alloc, "cpuCoreUsageAverage", 0) * hrs,
+          cpuReqCoreHrs: get(alloc, "cpuCoreRequestAverage", 0) * hrs,
+          ramUseByteHrs: get(alloc, "ramByteUsageAverage", 0) * hrs,
+          ramReqByteHrs: get(alloc, "ramByteRequestAverage", 0) * hrs,
+          cpuEfficiency: get(alloc, "cpuEfficiency", 0),
+          ramEfficiency: get(alloc, "ramEfficiency", 0),
+          totalEfficiency: get(alloc, "totalEfficiency", 0),
+        };
       } else {
-        const hrs = get(alloc, 'minutes', 0) / 60.0
-
-        result[alloc.name].cpuCost += get(alloc, 'cpuCost', 0)
-        result[alloc.name].gpuCost += get(alloc, 'gpuCost', 0)
-        result[alloc.name].ramCost += get(alloc, 'ramCost', 0)
-        result[alloc.name].pvCost += get(alloc, 'pvCost', 0)
-        result[alloc.name].networkCost += get(alloc, 'networkCost', 0)
-        result[alloc.name].sharedCost += get(alloc, 'sharedCost', 0)
-        result[alloc.name].externalCost += get(alloc, 'externalCost', 0)
-        result[alloc.name].totalCost += get(alloc, 'totalCost', 0)
-        result[alloc.name].cpuUseCoreHrs += get(alloc, 'cpuCoreUsageAverage', 0) * hrs
-        result[alloc.name].cpuReqCoreHrs += get(alloc, 'cpuCoreRequestAverage', 0) * hrs
-        result[alloc.name].ramUseByteHrs += get(alloc, 'ramByteUsageAverage', 0) * hrs
-        result[alloc.name].ramReqByteHrs += get(alloc, 'ramByteRequestAverage', 0) * hrs
+        const hrs = get(alloc, "minutes", 0) / 60.0;
+
+        result[alloc.name].cpuCost += get(alloc, "cpuCost", 0);
+        result[alloc.name].gpuCost += get(alloc, "gpuCost", 0);
+        result[alloc.name].ramCost += get(alloc, "ramCost", 0);
+        result[alloc.name].pvCost += get(alloc, "pvCost", 0);
+        result[alloc.name].networkCost += get(alloc, "networkCost", 0);
+        result[alloc.name].sharedCost += get(alloc, "sharedCost", 0);
+        result[alloc.name].externalCost += get(alloc, "externalCost", 0);
+        result[alloc.name].totalCost += get(alloc, "totalCost", 0);
+        result[alloc.name].cpuUseCoreHrs +=
+          get(alloc, "cpuCoreUsageAverage", 0) * hrs;
+        result[alloc.name].cpuReqCoreHrs +=
+          get(alloc, "cpuCoreRequestAverage", 0) * hrs;
+        result[alloc.name].ramUseByteHrs +=
+          get(alloc, "ramByteUsageAverage", 0) * hrs;
+        result[alloc.name].ramReqByteHrs +=
+          get(alloc, "ramByteRequestAverage", 0) * hrs;
       }
-    })
-  })
+    });
+  });
 
   // If the range is of length > 1 (i.e. it is not just a single set) then
   // compute efficiency for each result after accumulating.
   if (allocationSetRange.length > 1) {
     forEach(result, (alloc, name) => {
       // If we can't compute total efficiency, it defaults to 0.0
-      let totalEfficiency = 0.0
+      let totalEfficiency = 0.0;
 
       // CPU efficiency is defined as (usage/request). If request == 0.0 but
       // usage > 0, then efficiency gets set to 1.0.
-      let cpuEfficiency = 0.0
+      let cpuEfficiency = 0.0;
       if (alloc.cpuReqCoreHrs > 0) {
-        cpuEfficiency = alloc.cpuUseCoreHrs / alloc.cpuReqCoreHrs
+        cpuEfficiency = alloc.cpuUseCoreHrs / alloc.cpuReqCoreHrs;
       } else if (alloc.cpuUseCoreHrs > 0) {
-        cpuEfficiency = 1.0
+        cpuEfficiency = 1.0;
       }
 
       // RAM efficiency is defined as (usage/request). If request == 0.0 but
       // usage > 0, then efficiency gets set to 1.0.
-      let ramEfficiency = 0.0
+      let ramEfficiency = 0.0;
       if (alloc.ramReqByteHrs > 0) {
-        ramEfficiency = alloc.ramUseByteHrs / alloc.ramReqByteHrs
+        ramEfficiency = alloc.ramUseByteHrs / alloc.ramReqByteHrs;
       } else if (alloc.ramUseByteHrs > 0) {
-        ramEfficiency = 1.0
+        ramEfficiency = 1.0;
       }
 
       // Compute efficiency as the cost-weighted average of CPU and RAM
       // efficiency
-      if ((alloc.cpuCost + alloc.ramCost) > 0.0) {
-        totalEfficiency = ((alloc.cpuCost*cpuEfficiency)+(alloc.ramCost*ramEfficiency)) / (alloc.cpuCost + alloc.ramCost)
+      if (alloc.cpuCost + alloc.ramCost > 0.0) {
+        totalEfficiency =
+          (alloc.cpuCost * cpuEfficiency + alloc.ramCost * ramEfficiency) /
+          (alloc.cpuCost + alloc.ramCost);
       }
 
-      result[name].cpuEfficiency = cpuEfficiency
-      result[name].ramEfficiency = ramEfficiency
-      result[name].totalEfficiency = totalEfficiency
-    })
+      result[name].cpuEfficiency = cpuEfficiency;
+      result[name].ramEfficiency = ramEfficiency;
+      result[name].totalEfficiency = totalEfficiency;
+    });
   }
 
-  return result
+  return result;
 }
 
 // cumulativeToTotals adds each entry in the given AllocationSet (type: object)
 // and returns a single Allocation (type: object) representing the totals
 export function cumulativeToTotals(allocationSet) {
   let totals = {
-    name: 'Totals',
+    name: "Totals",
     cpuCost: 0,
     gpuCost: 0,
     ramCost: 0,
@@ -108,177 +115,223 @@ export function cumulativeToTotals(allocationSet) {
     cpuEfficiency: 0,
     ramEfficiency: 0,
     totalEfficiency: 0,
-  }
+  };
 
   // Use these for computing efficiency. As such, idle will not factor into
   // these numbers, including CPU and RAM cost.
-  let cpuReqCoreHrs = 0
-  let cpuUseCoreHrs = 0
-  let ramReqByteHrs = 0
-  let ramUseByteHrs = 0
-  let cpuCost = 0
-  let ramCost = 0
+  let cpuReqCoreHrs = 0;
+  let cpuUseCoreHrs = 0;
+  let ramReqByteHrs = 0;
+  let ramUseByteHrs = 0;
+  let cpuCost = 0;
+  let ramCost = 0;
 
   forEach(allocationSet, (alloc, name) => {
     // Accumulate efficiency-related fields
     if (name !== "__idle__") {
-      cpuReqCoreHrs += get(alloc, 'cpuReqCoreHrs', 0.0)
-      cpuUseCoreHrs += get(alloc, 'cpuUseCoreHrs', 0.0)
-      ramReqByteHrs += get(alloc, 'ramReqByteHrs', 0.0)
-      ramUseByteHrs += get(alloc, 'ramUseByteHrs', 0.0)
-      cpuCost += get(alloc, 'cpuCost', 0.0)
-      ramCost += get(alloc, 'ramCost', 0.0)
+      cpuReqCoreHrs += get(alloc, "cpuReqCoreHrs", 0.0);
+      cpuUseCoreHrs += get(alloc, "cpuUseCoreHrs", 0.0);
+      ramReqByteHrs += get(alloc, "ramReqByteHrs", 0.0);
+      ramUseByteHrs += get(alloc, "ramUseByteHrs", 0.0);
+      cpuCost += get(alloc, "cpuCost", 0.0);
+      ramCost += get(alloc, "ramCost", 0.0);
     }
 
     // Sum cumulative fields
-    totals.cpuCost += get(alloc, 'cpuCost', 0)
-    totals.gpuCost += get(alloc, 'gpuCost', 0)
-    totals.ramCost += get(alloc, 'ramCost', 0)
-    totals.pvCost += get(alloc, 'pvCost', 0)
-    totals.networkCost += get(alloc, 'networkCost', 0)
-    totals.sharedCost += get(alloc, 'sharedCost', 0)
-    totals.externalCost += get(alloc, 'externalCost', 0)
-    totals.totalCost += get(alloc, 'totalCost', 0)
-  })
+    totals.cpuCost += get(alloc, "cpuCost", 0);
+    totals.gpuCost += get(alloc, "gpuCost", 0);
+    totals.ramCost += get(alloc, "ramCost", 0);
+    totals.pvCost += get(alloc, "pvCost", 0);
+    totals.networkCost += get(alloc, "networkCost", 0);
+    totals.sharedCost += get(alloc, "sharedCost", 0);
+    totals.externalCost += get(alloc, "externalCost", 0);
+    totals.totalCost += get(alloc, "totalCost", 0);
+  });
 
   // Compute efficiency
   if (cpuReqCoreHrs > 0) {
-    totals.cpuEfficiency = cpuUseCoreHrs / cpuReqCoreHrs
+    totals.cpuEfficiency = cpuUseCoreHrs / cpuReqCoreHrs;
   } else if (cpuUseCoreHrs > 0) {
-    totals.cpuEfficiency = 1.0
+    totals.cpuEfficiency = 1.0;
   }
 
   if (ramReqByteHrs > 0) {
-    totals.ramEfficiency = ramUseByteHrs / ramReqByteHrs
+    totals.ramEfficiency = ramUseByteHrs / ramReqByteHrs;
   } else if (ramUseByteHrs > 0) {
-    totals.ramEfficiency = 1.0
+    totals.ramEfficiency = 1.0;
   }
 
-  if ((cpuCost + ramCost) > 0) {
-    totals.totalEfficiency = ((cpuCost*totals.cpuEfficiency) + (ramCost*totals.ramEfficiency)) / (cpuCost + ramCost)
+  if (cpuCost + ramCost > 0) {
+    totals.totalEfficiency =
+      (cpuCost * totals.cpuEfficiency + ramCost * totals.ramEfficiency) /
+      (cpuCost + ramCost);
   }
 
-  totals.cpuReqCoreHrs = cpuReqCoreHrs
-  totals.cpuUseCoreHrs = cpuUseCoreHrs
-  totals.ramReqByteHrs = ramReqByteHrs
-  totals.ramUseByteHrs = ramUseByteHrs
+  totals.cpuReqCoreHrs = cpuReqCoreHrs;
+  totals.cpuUseCoreHrs = cpuUseCoreHrs;
+  totals.ramReqByteHrs = ramReqByteHrs;
+  totals.ramUseByteHrs = ramUseByteHrs;
 
-  return totals
+  return totals;
 }
 
 export function toVerboseTimeRange(window) {
-  const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
-
-  const start = new Date()
-  start.setUTCHours(0, 0, 0, 0)
-
-  const end = new Date()
-  end.setUTCHours(0, 0, 0, 0)
+  const months = [
+    "January",
+    "February",
+    "March",
+    "April",
+    "May",
+    "June",
+    "July",
+    "August",
+    "September",
+    "October",
+    "November",
+    "December",
+  ];
+
+  const start = new Date();
+  start.setUTCHours(0, 0, 0, 0);
+
+  const end = new Date();
+  end.setUTCHours(0, 0, 0, 0);
 
   switch (window) {
-    case 'today':
-      return `${start.getUTCDate()} ${months[start.getUTCMonth()]} ${start.getUTCFullYear()}`
-    case 'yesterday':
-      start.setUTCDate(start.getUTCDate()-1)
-      return `${start.getUTCDate()} ${months[start.getUTCMonth()]} ${start.getUTCFullYear()}`
-    case 'week':
-      start.setUTCDate(start.getUTCDate()-start.getUTCDay())
-      return `${start.getUTCDate()} ${months[start.getUTCMonth()]} ${start.getUTCFullYear()} until now`
-    case 'month':
-      start.setUTCDate(1)
-      return `${start.getUTCDate()} ${months[start.getUTCMonth()]} ${start.getUTCFullYear()} until now`
-    case 'lastweek':
-      start.setUTCDate(start.getUTCDate()-(start.getUTCDay()+7))
-      end.setUTCDate(end.getUTCDate()-(end.getUTCDay()+1))
-      return `${start.getUTCDate()} ${months[start.getUTCMonth()]} ${start.getUTCFullYear()} through ${end.getUTCDate()} ${months[end.getUTCMonth()]} ${end.getUTCFullYear()}`
-    case 'lastmonth':
-      end.setUTCDate(1)
-      end.setUTCDate(end.getUTCDate()-1)
-      start.setUTCDate(1)
-      start.setUTCDate(start.getUTCDate()-1)
-      start.setUTCDate(1)
-      return `${start.getUTCDate()} ${months[start.getUTCMonth()]} ${start.getUTCFullYear()} through ${end.getUTCDate()} ${months[end.getUTCMonth()]} ${end.getUTCFullYear()}`
-    case '6d':
-        start.setUTCDate(start.getUTCDate()-6)
-        return `${start.getUTCDate()} ${months[start.getUTCMonth()]} ${start.getUTCFullYear()} through now`
-    case '29d':
-      start.setUTCDate(start.getUTCDate()-29)
-      return `${start.getUTCDate()} ${months[start.getUTCMonth()]} ${start.getUTCFullYear()} through now`
-    case '59d':
-      start.setUTCDate(start.getUTCDate()-59)
-      return `${start.getUTCDate()} ${months[start.getUTCMonth()]} ${start.getUTCFullYear()} through now`
-    case '89d':
-      start.setUTCDate(start.getUTCDate()-89)
-      return `${start.getUTCDate()} ${months[start.getUTCMonth()]} ${start.getUTCFullYear()} through now`
+    case "today":
+      return `${start.getUTCDate()} ${
+        months[start.getUTCMonth()]
+      } ${start.getUTCFullYear()}`;
+    case "yesterday":
+      start.setUTCDate(start.getUTCDate() - 1);
+      return `${start.getUTCDate()} ${
+        months[start.getUTCMonth()]
+      } ${start.getUTCFullYear()}`;
+    case "week":
+      start.setUTCDate(start.getUTCDate() - start.getUTCDay());
+      return `${start.getUTCDate()} ${
+        months[start.getUTCMonth()]
+      } ${start.getUTCFullYear()} until now`;
+    case "month":
+      start.setUTCDate(1);
+      return `${start.getUTCDate()} ${
+        months[start.getUTCMonth()]
+      } ${start.getUTCFullYear()} until now`;
+    case "lastweek":
+      start.setUTCDate(start.getUTCDate() - (start.getUTCDay() + 7));
+      end.setUTCDate(end.getUTCDate() - (end.getUTCDay() + 1));
+      return `${start.getUTCDate()} ${
+        months[start.getUTCMonth()]
+      } ${start.getUTCFullYear()} through ${end.getUTCDate()} ${
+        months[end.getUTCMonth()]
+      } ${end.getUTCFullYear()}`;
+    case "lastmonth":
+      end.setUTCDate(1);
+      end.setUTCDate(end.getUTCDate() - 1);
+      start.setUTCDate(1);
+      start.setUTCDate(start.getUTCDate() - 1);
+      start.setUTCDate(1);
+      return `${start.getUTCDate()} ${
+        months[start.getUTCMonth()]
+      } ${start.getUTCFullYear()} through ${end.getUTCDate()} ${
+        months[end.getUTCMonth()]
+      } ${end.getUTCFullYear()}`;
+    case "6d":
+      start.setUTCDate(start.getUTCDate() - 6);
+      return `${start.getUTCDate()} ${
+        months[start.getUTCMonth()]
+      } ${start.getUTCFullYear()} through now`;
+    case "29d":
+      start.setUTCDate(start.getUTCDate() - 29);
+      return `${start.getUTCDate()} ${
+        months[start.getUTCMonth()]
+      } ${start.getUTCFullYear()} through now`;
+    case "59d":
+      start.setUTCDate(start.getUTCDate() - 59);
+      return `${start.getUTCDate()} ${
+        months[start.getUTCMonth()]
+      } ${start.getUTCFullYear()} through now`;
+    case "89d":
+      start.setUTCDate(start.getUTCDate() - 89);
+      return `${start.getUTCDate()} ${
+        months[start.getUTCMonth()]
+      } ${start.getUTCFullYear()} through now`;
   }
 
-  const splitDates = window.split(",")
+  const splitDates = window.split(",");
   if (checkCustomWindow(window) && splitDates.length > 1) {
-    let s = splitDates[0].split(/\D+/).slice(0, 3)
-    let e = splitDates[1].split(/\D+/).slice(0, 3)
+    let s = splitDates[0].split(/\D+/).slice(0, 3);
+    let e = splitDates[1].split(/\D+/).slice(0, 3);
     if (s.length === 3 && e.length === 3) {
-      start.setUTCFullYear(s[0], s[1]-1, s[2])
-      end.setUTCFullYear(e[0], e[1]-1, e[2])
+      start.setUTCFullYear(s[0], s[1] - 1, s[2]);
+      end.setUTCFullYear(e[0], e[1] - 1, e[2]);
       if (start === end) {
-        return `${start.getUTCDate()} ${months[start.getUTCMonth()]} ${start.getUTCFullYear()}`
+        return `${start.getUTCDate()} ${
+          months[start.getUTCMonth()]
+        } ${start.getUTCFullYear()}`;
       } else {
-        return `${start.getUTCDate()} ${months[start.getUTCMonth()]} ${start.getUTCFullYear()} through ${end.getUTCDate()} ${months[end.getUTCMonth()]} ${end.getUTCFullYear()}`
+        return `${start.getUTCDate()} ${
+          months[start.getUTCMonth()]
+        } ${start.getUTCFullYear()} through ${end.getUTCDate()} ${
+          months[end.getUTCMonth()]
+        } ${end.getUTCFullYear()}`;
       }
     }
   }
-  return null
+  return null;
 }
 
 export function bytesToString(bytes) {
-  const ei = Math.pow(1024, 6)
+  const ei = Math.pow(1024, 6);
   if (bytes >= ei) {
-    return `${round(bytes/ei, 1)} EiB`
+    return `${round(bytes / ei, 1)} EiB`;
   }
-  const pi = Math.pow(1024, 5)
+  const pi = Math.pow(1024, 5);
   if (bytes >= pi) {
-    return `${round(bytes/pi, 1)} PiB`
+    return `${round(bytes / pi, 1)} PiB`;
   }
-  const ti = Math.pow(1024, 4)
+  const ti = Math.pow(1024, 4);
   if (bytes >= ti) {
-    return `${round(bytes/ti, 1)} TiB`
+    return `${round(bytes / ti, 1)} TiB`;
   }
-  const gi = Math.pow(1024, 3)
+  const gi = Math.pow(1024, 3);
   if (bytes >= gi) {
-    return `${round(bytes/gi, 1)} GiB`
+    return `${round(bytes / gi, 1)} GiB`;
   }
-  const mi = Math.pow(1024, 2)
+  const mi = Math.pow(1024, 2);
   if (bytes >= mi) {
-    return `${round(bytes/mi, 1)} MiB`
+    return `${round(bytes / mi, 1)} MiB`;
   }
-  const ki = Math.pow(1024, 1)
+  const ki = Math.pow(1024, 1);
   if (bytes >= ki) {
-    return `${round(bytes/ki, 1)} KiB`
+    return `${round(bytes / ki, 1)} KiB`;
   }
 
-  return `${round(bytes, 1)} B`
+  return `${round(bytes, 1)} B`;
 }
 
-const currencyLocale = "en-US"
+const currencyLocale = "en-US";
 
 export function toCurrency(amount, currency, precision) {
   if (typeof amount !== "number") {
-    console.warn(`Tried to convert "${amount}" to currency, but it is not a number`)
-    return ""
+    console.warn(
+      `Tried to convert "${amount}" to currency, but it is not a number`
+    );
+    return "";
   }
 
   if (currency === undefined || currency === "") {
-    currency = "USD"
+    currency = "USD";
   }
 
   const opts = {
     style: "currency",
     currency: currency,
-
-  }
+  };
 
   if (typeof precision === "number") {
-    opts.minimumFractionDigits = precision
-    opts.maximumFractionDigits = precision
+    opts.minimumFractionDigits = precision;
+    opts.maximumFractionDigits = precision;
   }
 
   return amount.toLocaleString(currencyLocale, opts);
@@ -286,11 +339,123 @@ export function toCurrency(amount, currency, precision) {
 
 export function checkCustomWindow(window) {
   // Example ISO interval string: 2020-12-02T00:00:00Z,2020-12-03T23:59:59Z
-  const customDateRegex = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z,\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z/
-  return customDateRegex.test(window)
+  const customDateRegex =
+    /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z,\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z/;
+  return customDateRegex.test(window);
 }
 
+export function getCloudFilters(filters) {
+  const filterNamesMap = {
+    "invoice entity": "filterInvoiceEntityIDs",
+    provider: "filterProviders",
+    providerids: "filterProviderIDs",
+    service: "filterServices",
+    account: "filterAccountIDs",
+  };
+  const params = new URLSearchParams();
+  const labelFilters = [];
+
+  for (let filter of filters) {
+    const mapped = filterNamesMap[filter.property.toLowerCase()];
+
+    if (mapped) {
+      params.set(mapped, filter.value);
+    } else if (filter.property === "Labels") {
+      labelFilters.push(filter.value);
+    } else if (filter.property.startsWith(":")) {
+      labelFilters.push(`${filter.property.slice(6)}:${filter.value}`);
+    }
+  }
+  if (labelFilters.length) {
+    params.set("filterLabels", labelFilters.join(","));
+  }
+
+  return `&${params.toString()}`;
+}
+
+export function formatSampleItemsForGraph({ data, costMetric }) {
+  const costMetricPropName = costMetric
+    ? costMetricToPropName[costMetric]
+    : "amortizedNetCost";
+  const graphData = data.sets.map(({ cloudCosts, window: { end, start } }) => {
+    return {
+      end,
+      items: Object.entries(cloudCosts).map(([name, item]) => ({
+        name,
+        value: item.netCost.cost,
+      })),
+      start,
+    };
+  });
+  const accumulator = {};
+  data.sets.forEach(({ cloudCosts, window }) => {
+    Object.entries(cloudCosts).forEach(([name, cloudCostItem]) => {
+      const { properties } = cloudCostItem;
+      accumulator[name] ||= {
+        cost: 0,
+        start: "",
+        end: "",
+        providerID: "",
+        labelName: "",
+        kubernetesCost: 0,
+        kubernetesPercent: 0,
+      };
+      accumulator[name].cost += cloudCostItem[costMetricPropName].cost;
+      accumulator[name].kubernetesCost +=
+        cloudCostItem[costMetricPropName].cost *
+        cloudCostItem[costMetricPropName].kubernetesPercent;
+      accumulator[name].start = window.start;
+      accumulator[name].end = window.end;
+      accumulator[name].providerID = properties.providerID;
+      accumulator[name].labelName = properties.labels?.name;
+      accumulator[name].kubernetesPercent =
+        cloudCostItem[costMetricPropName].kubernetesPercent;
+    });
+  });
+  const tableRows = Object.entries(accumulator).map(
+    ([
+      name,
+      {
+        cost,
+        start,
+        end,
+        providerID,
+        kubernetesCost,
+        kubernetesPercent,
+        labelName,
+      },
+    ]) => ({
+      cost,
+      name,
+      kubernetesCost,
+      kubernetesPercent,
+      start,
+      end,
+      providerID,
+      labelName,
+    })
+  );
+
+  const tableTotal = tableRows.reduce(
+    (tr1, tr2) => ({
+      ...tr1,
+      cost: tr1.cost + tr2.cost,
+      kubernetesCost: tr1.kubernetesCost + tr2.kubernetesCost,
+    }),
+    {
+      cost: 0,
+      name: "",
+      kubernetesCost: 0,
+      kubernetesPercent: 0,
+      end: "",
+      start: "",
+      labelName: "",
+      providerID: "",
+    }
+  );
 
+  return { graphData, tableRows, tableTotal };
+}
 
 export default {
   rangeToCumulative,
@@ -299,4 +464,4 @@ export default {
   bytesToString,
   toCurrency,
   checkCustomWindow,
-}
+};

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است