Просмотр исходного кода

Merge branch 'develop' into feat/arm-node-exporter

Mark 3 лет назад
Родитель
Сommit
fd94a557c8

+ 53 - 17
pkg/filter/util/cloudcost.go

@@ -1,6 +1,7 @@
 package util
 
 import (
+	"reflect"
 	"strings"
 
 	"github.com/opencost/opencost/pkg/filter"
@@ -9,48 +10,83 @@ import (
 	"github.com/opencost/opencost/pkg/util/mapper"
 )
 
+type CloudCostFilter struct {
+	AccountIDs       []string `json:"accountIDs"`
+	Categories       []string `json:"categories"`
+	InvoiceEntityIDs []string `json:"invoiceEntityIDs"`
+	Labels           []string `json:"labels"`
+	Providers        []string `json:"providers"`
+	ProviderIDs      []string `json:"providerIDs"`
+	Services         []string `json:"services"`
+}
+
+func (g *CloudCostFilter) Equals(that CloudCostFilter) bool {
+	return reflect.DeepEqual(g.AccountIDs, that.AccountIDs) &&
+		reflect.DeepEqual(g.Categories, that.Categories) &&
+		reflect.DeepEqual(g.InvoiceEntityIDs, that.InvoiceEntityIDs) &&
+		reflect.DeepEqual(g.Labels, that.Labels) &&
+		reflect.DeepEqual(g.Providers, that.Providers) &&
+		reflect.DeepEqual(g.ProviderIDs, that.ProviderIDs) &&
+		reflect.DeepEqual(g.Services, that.Services)
+}
 func parseWildcardEnd(rawFilterValue string) (string, bool) {
 	return strings.TrimSuffix(rawFilterValue, "*"), strings.HasSuffix(rawFilterValue, "*")
 }
 
 func CloudCostFilterFromParams(pmr mapper.PrimitiveMapReader) filter.Filter[*kubecost.CloudCost] {
-	filter := filter.And[*kubecost.CloudCost]{
+	ccFilter := convertFilterQueryParams(pmr)
+	return ParseCloudCostFilter(ccFilter)
+}
+
+func convertFilterQueryParams(pmr mapper.PrimitiveMapReader) CloudCostFilter {
+	return CloudCostFilter{
+		AccountIDs:       pmr.GetList("filterAccountIDs", ","),
+		Categories:       pmr.GetList("filterCategories", ","),
+		InvoiceEntityIDs: pmr.GetList("filterInvoiceEntityIDs", ","),
+		Labels:           pmr.GetList("filterLabels", ","),
+		Providers:        pmr.GetList("filterProviders", ","),
+		ProviderIDs:      pmr.GetList("filterProviderIDs", ","),
+		Services:         pmr.GetList("filterServices", ","),
+	}
+}
+func ParseCloudCostFilter(filters CloudCostFilter) filter.Filter[*kubecost.CloudCost] {
+	result := filter.And[*kubecost.CloudCost]{
 		Filters: []filter.Filter[*kubecost.CloudCost]{},
 	}
 
-	if raw := pmr.GetList("filterInvoiceEntityIDs", ","); len(raw) > 0 {
-		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(raw, kubecost.CloudCostInvoiceEntityIDProp))
+	if len(filters.InvoiceEntityIDs) > 0 {
+		result.Filters = append(result.Filters, filterV1SingleValueFromList(filters.InvoiceEntityIDs, kubecost.CloudCostInvoiceEntityIDProp))
 	}
 
-	if raw := pmr.GetList("filterAccountIDs", ","); len(raw) > 0 {
-		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(raw, kubecost.CloudCostAccountIDProp))
+	if len(filters.AccountIDs) > 0 {
+		result.Filters = append(result.Filters, filterV1SingleValueFromList(filters.AccountIDs, kubecost.CloudCostAccountIDProp))
 	}
 
-	if raw := pmr.GetList("filterProviders", ","); len(raw) > 0 {
-		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(raw, kubecost.CloudCostProviderProp))
+	if len(filters.Providers) > 0 {
+		result.Filters = append(result.Filters, filterV1SingleValueFromList(filters.Providers, kubecost.CloudCostProviderProp))
 	}
 
-	if raw := pmr.GetList("filterProviderIDs", ","); len(raw) > 0 {
-		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(raw, kubecost.CloudCostProviderIDProp))
+	if len(filters.ProviderIDs) > 0 {
+		result.Filters = append(result.Filters, filterV1SingleValueFromList(filters.ProviderIDs, kubecost.CloudCostProviderIDProp))
 	}
 
-	if raw := pmr.GetList("filterServices", ","); len(raw) > 0 {
-		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(raw, kubecost.CloudCostServiceProp))
+	if len(filters.Services) > 0 {
+		result.Filters = append(result.Filters, filterV1SingleValueFromList(filters.Services, kubecost.CloudCostServiceProp))
 	}
 
-	if raw := pmr.GetList("filterCategories", ","); len(raw) > 0 {
-		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(raw, kubecost.CloudCostCategoryProp))
+	if len(filters.Categories) > 0 {
+		result.Filters = append(result.Filters, filterV1SingleValueFromList(filters.Categories, kubecost.CloudCostCategoryProp))
 	}
 
-	if raw := pmr.GetList("filterLabels", ","); len(raw) > 0 {
-		filter.Filters = append(filter.Filters, filterV1DoubleValueFromList(raw, kubecost.CloudCostLabelProp))
+	if len(filters.Labels) > 0 {
+		result.Filters = append(result.Filters, filterV1DoubleValueFromList(filters.Labels, kubecost.CloudCostLabelProp))
 	}
 
-	if len(filter.Filters) == 0 {
+	if len(result.Filters) == 0 {
 		return nil
 	}
 
-	return filter
+	return result
 }
 
 func filterV1SingleValueFromList(rawFilterValues []string, field string) filter.Filter[*kubecost.CloudCost] {

+ 134 - 0
pkg/filter/util/cloudcost_test.go

@@ -0,0 +1,134 @@
+package util
+
+import (
+	"testing"
+)
+
+type CloudCostFilterEqualsTestcase struct {
+	name     string
+	this     CloudCostFilter
+	that     CloudCostFilter
+	expected bool
+}
+
+func TestCloudCostFilter_Equals(t *testing.T) {
+	testCases := []CloudCostFilterEqualsTestcase{
+		{
+			name: "both filters nil",
+			this: CloudCostFilter{
+				AccountIDs:       nil,
+				Categories:       nil,
+				InvoiceEntityIDs: nil,
+				Labels:           nil,
+				Providers:        nil,
+				ProviderIDs:      nil,
+				Services:         nil,
+			},
+			that: CloudCostFilter{
+				AccountIDs:       nil,
+				Categories:       nil,
+				InvoiceEntityIDs: nil,
+				Labels:           nil,
+				Providers:        nil,
+				ProviderIDs:      nil,
+				Services:         nil,
+			},
+			expected: true,
+		},
+		{
+			name: "both filters not nil and matching",
+			this: CloudCostFilter{
+				AccountIDs:       []string{"account1", "account2", "account3"},
+				Categories:       []string{"category1", "category2"},
+				InvoiceEntityIDs: []string{"invoice1", "invoice2"},
+				Labels:           []string{"label1", "label2"},
+				Providers:        []string{"provider1", "provider2"},
+				ProviderIDs:      []string{"provider1", "provider2"},
+				Services:         []string{"s1"},
+			},
+			that: CloudCostFilter{
+				AccountIDs:       []string{"account1", "account2", "account3"},
+				Categories:       []string{"category1", "category2"},
+				InvoiceEntityIDs: []string{"invoice1", "invoice2"},
+				Labels:           []string{"label1", "label2"},
+				Providers:        []string{"provider1", "provider2"},
+				ProviderIDs:      []string{"provider1", "provider2"},
+				Services:         []string{"s1"},
+			},
+			expected: true,
+		},
+		{
+			name: "both filters diff count",
+			this: CloudCostFilter{
+				AccountIDs:       []string{"account1", "account2", "account3"},
+				Categories:       []string{"category1", "category2"},
+				InvoiceEntityIDs: []string{"invoice1", "invoice2"},
+				Labels:           []string{"label1", "label2"},
+				Providers:        []string{"provider1", "provider2"},
+				ProviderIDs:      []string{"provider1", "provider2"},
+				Services:         []string{"s1"},
+			},
+			that: CloudCostFilter{
+				AccountIDs:       []string{"account1", "account2"},
+				Categories:       []string{"category1", "category2"},
+				InvoiceEntityIDs: []string{"invoice1", "invoice2"},
+				Labels:           []string{"label1", "label2"},
+				Providers:        []string{"provider1", "provider2"},
+				ProviderIDs:      []string{"provider1", "provider2"},
+				Services:         []string{"s1"},
+			},
+			expected: false,
+		},
+		{
+			name: "slight mismatch",
+			this: CloudCostFilter{
+				AccountIDs:       []string{"account1", "account2", "account3"},
+				Categories:       []string{"category1", "category2"},
+				InvoiceEntityIDs: []string{"invoice1", "invoice2"},
+				Labels:           []string{"label1", "label2"},
+				Providers:        []string{"provider1", "provider2"},
+				ProviderIDs:      []string{"provider1", "provider2"},
+				Services:         []string{"s1"},
+			},
+			that: CloudCostFilter{
+				AccountIDs:       []string{"account10", "account2", "account3"},
+				Categories:       []string{"category1", "category2"},
+				InvoiceEntityIDs: []string{"invoice1", "invoice2"},
+				Labels:           []string{"label1", "label2"},
+				Providers:        []string{"provider1", "provider2"},
+				ProviderIDs:      []string{"provider1", "provider2"},
+				Services:         []string{"s1"},
+			},
+			expected: false,
+		},
+		{
+			name: "one nil, one not",
+			this: CloudCostFilter{
+				AccountIDs:       []string{"account1", "account2", "account3"},
+				Categories:       []string{"category1", "category2"},
+				InvoiceEntityIDs: []string{"invoice1", "invoice2"},
+				Labels:           []string{"label1", "label2"},
+				Providers:        []string{"provider1", "provider2"},
+				ProviderIDs:      []string{"provider1", "provider2"},
+				Services:         []string{"s1"},
+			},
+			that: CloudCostFilter{
+				AccountIDs:       nil,
+				Categories:       nil,
+				InvoiceEntityIDs: nil,
+				Labels:           nil,
+				Providers:        nil,
+				ProviderIDs:      nil,
+				Services:         nil,
+			},
+			expected: false,
+		},
+	}
+
+	for _, tc := range testCases {
+		got := tc.this.Equals(tc.that)
+		if got != tc.expected {
+			t.Fatalf("expected %t, got: %t for test case: %s", tc.expected, got, tc.name)
+		}
+	}
+}

+ 119 - 38
pkg/util/allocationfilterutil/queryfilters.go

@@ -2,6 +2,7 @@ package allocationfilterutil
 
 import (
 	"fmt"
+	"reflect"
 	"strings"
 
 	"github.com/opencost/opencost/pkg/costmodel/clusters"
@@ -81,6 +82,42 @@ func AllHTTPParamKeys() []string {
 	}
 }
 
+type FilterV1 struct {
+	Annotations     []string `json:"annotations"`
+	Containers      []string `json:"containers"`
+	Controllers     []string `json:"controllers"`
+	ControllerKinds []string `json:"controllerKinds"`
+	Clusters        []string `json:"clusters"`
+	Departments     []string `json:"departments"`
+	Environments    []string `json:"environments"`
+	Labels          []string `json:"labels"`
+	Namespaces      []string `json:"namespaces"`
+	Nodes           []string `json:"nodes"`
+	Owners          []string `json:"owners"`
+	Pods            []string `json:"pods"`
+	Products        []string `json:"products"`
+	Services        []string `json:"services"`
+	Teams           []string `json:"teams"`
+}
+
+func (f FilterV1) Equals(that FilterV1) bool {
+	return reflect.DeepEqual(f.Annotations, that.Annotations) &&
+		reflect.DeepEqual(f.Containers, that.Containers) &&
+		reflect.DeepEqual(f.Controllers, that.Controllers) &&
+		reflect.DeepEqual(f.ControllerKinds, that.ControllerKinds) &&
+		reflect.DeepEqual(f.Clusters, that.Clusters) &&
+		reflect.DeepEqual(f.Departments, that.Departments) &&
+		reflect.DeepEqual(f.Environments, that.Environments) &&
+		reflect.DeepEqual(f.Labels, that.Labels) &&
+		reflect.DeepEqual(f.Namespaces, that.Namespaces) &&
+		reflect.DeepEqual(f.Nodes, that.Nodes) &&
+		reflect.DeepEqual(f.Owners, that.Owners) &&
+		reflect.DeepEqual(f.Pods, that.Pods) &&
+		reflect.DeepEqual(f.Products, that.Products) &&
+		reflect.DeepEqual(f.Services, that.Services) &&
+		reflect.DeepEqual(f.Teams, that.Teams)
+}
+
 // ============================================================================
 // This file contains:
 // Parsing (HTTP query params -> AllocationFilter) for V1 of filters
@@ -98,7 +135,7 @@ func parseWildcardEnd(rawFilterValue string) (string, bool) {
 	return strings.TrimSuffix(rawFilterValue, "*"), strings.HasSuffix(rawFilterValue, "*")
 }
 
-// AllocationFilterFromParamsV1 takes a set of HTTP query parameters and
+// ParseAllocationFilterV1 takes a FilterV1 struct and
 // converts them to an AllocationFilter, which is a structured in-Go
 // representation of a set of filters.
 //
@@ -111,12 +148,7 @@ func parseWildcardEnd(rawFilterValue string) (string, bool) {
 // It takes an optional ClusterMap, which if provided enables cluster name
 // filtering. This turns all `filterClusters=foo` arguments into the equivalent
 // of `clusterID = "foo" OR clusterName = "foo"`.
-func AllocationFilterFromParamsV1(
-	qp mapper.PrimitiveMapReader,
-	labelConfig *kubecost.LabelConfig,
-	clusterMap clusters.ClusterMap,
-) kubecost.AllocationFilter {
-
+func ParseAllocationFilterV1(filters FilterV1, labelConfig *kubecost.LabelConfig, clusterMap clusters.ClusterMap) kubecost.AllocationFilter {
 	filter := kubecost.AllocationFilterAnd{
 		Filters: []kubecost.AllocationFilter{},
 	}
@@ -154,15 +186,15 @@ func AllocationFilterFromParamsV1(
 	// filter structs (they evaluate to true always) there could be overhead
 	// when calling Matches() repeatedly for no purpose.
 
-	if filterClusters := qp.GetList(ParamFilterClusters, ","); len(filterClusters) > 0 {
+	if len(filters.Clusters) > 0 {
 		clustersOr := kubecost.AllocationFilterOr{
 			Filters: []kubecost.AllocationFilter{},
 		}
 
-		if idFilters := filterV1SingleValueFromList(filterClusters, kubecost.FilterClusterID); len(idFilters.Filters) > 0 {
+		if idFilters := filterV1SingleValueFromList(filters.Clusters, kubecost.FilterClusterID); len(idFilters.Filters) > 0 {
 			clustersOr.Filters = append(clustersOr.Filters, idFilters)
 		}
-		for _, rawFilterValue := range filterClusters {
+		for _, rawFilterValue := range filters.Clusters {
 			clusterNameFilter, wildcard := parseWildcardEnd(rawFilterValue)
 
 			clusterIDsToFilter := []string{}
@@ -187,27 +219,27 @@ func AllocationFilterFromParamsV1(
 		filter.Filters = append(filter.Filters, clustersOr)
 	}
 
-	if raw := qp.GetList(ParamFilterNodes, ","); len(raw) > 0 {
-		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(raw, kubecost.FilterNode))
+	if len(filters.Nodes) > 0 {
+		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(filters.Nodes, kubecost.FilterNode))
 	}
 
-	if raw := qp.GetList(ParamFilterNamespaces, ","); len(raw) > 0 {
-		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(raw, kubecost.FilterNamespace))
+	if len(filters.Namespaces) > 0 {
+		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(filters.Namespaces, kubecost.FilterNamespace))
 	}
 
-	if raw := qp.GetList(ParamFilterControllerKinds, ","); len(raw) > 0 {
-		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(raw, kubecost.FilterControllerKind))
+	if len(filters.ControllerKinds) > 0 {
+		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(filters.ControllerKinds, kubecost.FilterControllerKind))
 	}
 
 	// filterControllers= accepts controllerkind:controllername filters, e.g.
 	// "deployment:kubecost-cost-analyzer"
 	//
 	// Thus, we have to make a custom OR filter for this condition.
-	if filterControllers := qp.GetList(ParamFilterControllers, ","); len(filterControllers) > 0 {
+	if len(filters.Controllers) > 0 {
 		controllersOr := kubecost.AllocationFilterOr{
 			Filters: []kubecost.AllocationFilter{},
 		}
-		for _, rawFilterValue := range filterControllers {
+		for _, rawFilterValue := range filters.Controllers {
 			split := strings.Split(rawFilterValue, ":")
 			if len(split) == 1 {
 				filterValue, wildcard := parseWildcardEnd(split[0])
@@ -254,49 +286,49 @@ func AllocationFilterFromParamsV1(
 		}
 	}
 
-	if raw := qp.GetList(ParamFilterPods, ","); len(raw) > 0 {
-		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(raw, kubecost.FilterPod))
+	if len(filters.Pods) > 0 {
+		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(filters.Pods, kubecost.FilterPod))
 	}
 
-	if raw := qp.GetList(ParamFilterContainers, ","); len(raw) > 0 {
-		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(raw, kubecost.FilterContainer))
+	if len(filters.Containers) > 0 {
+		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(filters.Containers, kubecost.FilterContainer))
 	}
 
 	// Label-mapped queries require a label config to be present.
 	if labelConfig != nil {
-		if raw := qp.GetList(ParamFilterDepartments, ","); len(raw) > 0 {
-			filter.Filters = append(filter.Filters, filterV1LabelAliasMappedFromList(raw, labelConfig.DepartmentLabel))
+		if len(filters.Departments) > 0 {
+			filter.Filters = append(filter.Filters, filterV1LabelAliasMappedFromList(filters.Departments, labelConfig.DepartmentLabel))
 		}
-		if raw := qp.GetList(ParamFilterEnvironments, ","); len(raw) > 0 {
-			filter.Filters = append(filter.Filters, filterV1LabelAliasMappedFromList(raw, labelConfig.EnvironmentLabel))
+		if len(filters.Environments) > 0 {
+			filter.Filters = append(filter.Filters, filterV1LabelAliasMappedFromList(filters.Environments, labelConfig.EnvironmentLabel))
 		}
-		if raw := qp.GetList(ParamFilterOwners, ","); len(raw) > 0 {
-			filter.Filters = append(filter.Filters, filterV1LabelAliasMappedFromList(raw, labelConfig.OwnerLabel))
+		if len(filters.Owners) > 0 {
+			filter.Filters = append(filter.Filters, filterV1LabelAliasMappedFromList(filters.Owners, labelConfig.OwnerLabel))
 		}
-		if raw := qp.GetList(ParamFilterProducts, ","); len(raw) > 0 {
-			filter.Filters = append(filter.Filters, filterV1LabelAliasMappedFromList(raw, labelConfig.ProductLabel))
+		if len(filters.Products) > 0 {
+			filter.Filters = append(filter.Filters, filterV1LabelAliasMappedFromList(filters.Products, labelConfig.ProductLabel))
 		}
-		if raw := qp.GetList(ParamFilterTeams, ","); len(raw) > 0 {
-			filter.Filters = append(filter.Filters, filterV1LabelAliasMappedFromList(raw, labelConfig.TeamLabel))
+		if len(filters.Teams) > 0 {
+			filter.Filters = append(filter.Filters, filterV1LabelAliasMappedFromList(filters.Teams, labelConfig.TeamLabel))
 		}
 	} else {
 		log.Debugf("No label config is available. Not creating filters for label-mapped 'fields'.")
 	}
 
-	if raw := qp.GetList(ParamFilterAnnotations, ","); len(raw) > 0 {
-		filter.Filters = append(filter.Filters, filterV1DoubleValueFromList(raw, kubecost.FilterAnnotation))
+	if len(filters.Annotations) > 0 {
+		filter.Filters = append(filter.Filters, filterV1DoubleValueFromList(filters.Annotations, kubecost.FilterAnnotation))
 	}
 
-	if raw := qp.GetList(ParamFilterLabels, ","); len(raw) > 0 {
-		filter.Filters = append(filter.Filters, filterV1DoubleValueFromList(raw, kubecost.FilterLabel))
+	if len(filters.Labels) > 0 {
+		filter.Filters = append(filter.Filters, filterV1DoubleValueFromList(filters.Labels, kubecost.FilterLabel))
 	}
 
-	if filterServices := qp.GetList(ParamFilterServices, ","); len(filterServices) > 0 {
+	if len(filters.Services) > 0 {
 		// filterServices= is the only filter that uses the "contains" operator.
 		servicesFilter := kubecost.AllocationFilterOr{
 			Filters: []kubecost.AllocationFilter{},
 		}
-		for _, filterValue := range filterServices {
+		for _, filterValue := range filters.Services {
 			// TODO: wildcard support
 			filterValue, wildcard := parseWildcardEnd(filterValue)
 			subFilter := kubecost.AllocationFilterCondition{
@@ -315,6 +347,28 @@ func AllocationFilterFromParamsV1(
 	return filter
 }
 
+// AllocationFilterFromParamsV1 takes a set of HTTP query parameters and
+// converts them to an AllocationFilter, which is a structured in-Go
+// representation of a set of filters.
+//
+// The HTTP query parameters are the "v1" filters attached to the Allocation
+// API: "filterNamespaces=", "filterNodes=", etc.
+//
+// It takes an optional LabelConfig, which if provided enables "label-mapped"
+// filters like "filterDepartments".
+//
+// It takes an optional ClusterMap, which if provided enables cluster name
+// filtering. This turns all `filterClusters=foo` arguments into the equivalent
+// of `clusterID = "foo" OR clusterName = "foo"`.
+func AllocationFilterFromParamsV1(
+	qp mapper.PrimitiveMapReader,
+	labelConfig *kubecost.LabelConfig,
+	clusterMap clusters.ClusterMap,
+) kubecost.AllocationFilter {
+	filter := ConvertFilterQueryParams(qp, labelConfig)
+	return ParseAllocationFilterV1(filter, labelConfig, clusterMap)
+}
+
 // filterV1SingleValueFromList creates an OR of equality filters for a given
 // filter field.
 //
@@ -346,6 +400,33 @@ func filterV1SingleValueFromList(rawFilterValues []string, filterField kubecost.
 	return filter
 }
 
+func ConvertFilterQueryParams(qp mapper.PrimitiveMapReader, labelConfig *kubecost.LabelConfig) FilterV1 {
+	filter := FilterV1{
+		Annotations:     qp.GetList(ParamFilterAnnotations, ","),
+		Containers:      qp.GetList(ParamFilterContainers, ","),
+		Controllers:     qp.GetList(ParamFilterControllers, ","),
+		ControllerKinds: qp.GetList(ParamFilterControllerKinds, ","),
+		Clusters:        qp.GetList(ParamFilterClusters, ","),
+		Labels:          qp.GetList(ParamFilterLabels, ","),
+		Namespaces:      qp.GetList(ParamFilterNamespaces, ","),
+		Nodes:           qp.GetList(ParamFilterNodes, ","),
+		Pods:            qp.GetList(ParamFilterPods, ","),
+		Services:        qp.GetList(ParamFilterServices, ","),
+	}
+
+	if labelConfig != nil {
+		filter.Departments = qp.GetList(ParamFilterDepartments, ",")
+		filter.Environments = qp.GetList(ParamFilterEnvironments, ",")
+		filter.Owners = qp.GetList(ParamFilterOwners, ",")
+		filter.Products = qp.GetList(ParamFilterProducts, ",")
+		filter.Teams = qp.GetList(ParamFilterTeams, ",")
+	} else {
+		log.Debugf("No label config is available. Not creating filters for label-mapped 'fields'.")
+	}
+
+	return filter
+}
+
 // filterV1LabelAliasMappedFromList is like filterV1SingleValueFromList but is
 // explicitly for labels and annotations because "label-mapped" filters (like filterTeams=)
 // are actually label filters with a fixed label key.

+ 209 - 0
pkg/util/allocationfilterutil/queryfilters_test.go

@@ -719,3 +719,212 @@ func TestFiltersFromParamsV1(t *testing.T) {
 		})
 	}
 }
+
+type FilterV1EqualsTestcase struct {
+	name     string
+	this     FilterV1
+	that     FilterV1
+	expected bool
+}
+
+func TestFilterV1_Equals(t *testing.T) {
+	testCases := []FilterV1EqualsTestcase{
+		{
+			name: "both filters nil",
+			this: FilterV1{
+				Annotations:     nil,
+				Containers:      nil,
+				Controllers:     nil,
+				ControllerKinds: nil,
+				Clusters:        nil,
+				Departments:     nil,
+				Environments:    nil,
+				Labels:          nil,
+				Namespaces:      nil,
+				Nodes:           nil,
+				Owners:          nil,
+				Pods:            nil,
+				Products:        nil,
+				Services:        nil,
+				Teams:           nil,
+			},
+			that: FilterV1{
+				Annotations:     nil,
+				Containers:      nil,
+				Controllers:     nil,
+				ControllerKinds: nil,
+				Clusters:        nil,
+				Departments:     nil,
+				Environments:    nil,
+				Labels:          nil,
+				Namespaces:      nil,
+				Nodes:           nil,
+				Owners:          nil,
+				Pods:            nil,
+				Products:        nil,
+				Services:        nil,
+				Teams:           nil,
+			},
+			expected: true,
+		},
+		{
+			name: "both filters not nil and matching",
+			this: FilterV1{
+				Annotations:     []string{"a1", "b1"},
+				Containers:      []string{"a1", "b1"},
+				Controllers:     []string{"a1", "b1"},
+				ControllerKinds: []string{"a1", "b1"},
+				Clusters:        []string{"a1", "b1"},
+				Departments:     []string{"a1", "b1"},
+				Environments:    []string{"a1", "b1"},
+				Labels:          []string{"a1", "b1"},
+				Namespaces:      []string{"a1", "b1"},
+				Nodes:           []string{"a1", "b1"},
+				Owners:          []string{"a1", "b1"},
+				Pods:            []string{"a1", "b1"},
+				Products:        []string{"a1", "b1"},
+				Services:        []string{"a1", "b1"},
+				Teams:           []string{"a1", "b1"},
+			},
+			that: FilterV1{
+				Annotations:     []string{"a1", "b1"},
+				Containers:      []string{"a1", "b1"},
+				Controllers:     []string{"a1", "b1"},
+				ControllerKinds: []string{"a1", "b1"},
+				Clusters:        []string{"a1", "b1"},
+				Departments:     []string{"a1", "b1"},
+				Environments:    []string{"a1", "b1"},
+				Labels:          []string{"a1", "b1"},
+				Namespaces:      []string{"a1", "b1"},
+				Nodes:           []string{"a1", "b1"},
+				Owners:          []string{"a1", "b1"},
+				Pods:            []string{"a1", "b1"},
+				Products:        []string{"a1", "b1"},
+				Services:        []string{"a1", "b1"},
+				Teams:           []string{"a1", "b1"},
+			},
+			expected: true,
+		},
+		{
+			name: "both filters diff count",
+			this: FilterV1{
+				Annotations:     []string{"a1", "b1", "c1"},
+				Containers:      []string{"a1", "b1"},
+				Controllers:     []string{"a1", "b1"},
+				ControllerKinds: []string{"a1", "b1"},
+				Clusters:        []string{"a1", "b1"},
+				Departments:     []string{"a1", "b1"},
+				Environments:    []string{"a1", "b1"},
+				Labels:          []string{"a1", "b1"},
+				Namespaces:      []string{"a1", "b1"},
+				Nodes:           []string{"a1", "b1"},
+				Owners:          []string{"a1", "b1"},
+				Pods:            []string{"a1", "b1"},
+				Products:        []string{"a1", "b1"},
+				Services:        []string{"a1", "b1"},
+				Teams:           []string{"a1", "b1"},
+			},
+			that: FilterV1{
+				Annotations:     []string{"a1", "b1"},
+				Containers:      []string{"a1", "b1"},
+				Controllers:     []string{"a1", "b1"},
+				ControllerKinds: []string{"a1", "b1"},
+				Clusters:        []string{"a1", "b1"},
+				Departments:     []string{"a1", "b1"},
+				Environments:    []string{"a1", "b1"},
+				Labels:          []string{"a1", "b1"},
+				Namespaces:      []string{"a1", "b1"},
+				Nodes:           []string{"a1", "b1"},
+				Owners:          []string{"a1", "b1"},
+				Pods:            []string{"a1", "b1"},
+				Products:        []string{"a1", "b1"},
+				Services:        []string{"a1", "b1"},
+				Teams:           []string{"a1", "b1"},
+			},
+			expected: false,
+		},
+		{
+			name: "slight mismatch",
+			this: FilterV1{
+				Annotations:     []string{"x1", "b1"},
+				Containers:      []string{"a1", "b1"},
+				Controllers:     []string{"a1", "b1"},
+				ControllerKinds: []string{"a1", "b1"},
+				Clusters:        []string{"a1", "b1"},
+				Departments:     []string{"a1", "b1"},
+				Environments:    []string{"a1", "b1"},
+				Labels:          []string{"a1", "b1"},
+				Namespaces:      []string{"a1", "b1"},
+				Nodes:           []string{"a1", "b1"},
+				Owners:          []string{"a1", "b1"},
+				Pods:            []string{"a1", "b1"},
+				Products:        []string{"a1", "b1"},
+				Services:        []string{"a1", "b1"},
+				Teams:           []string{"a1", "b1"},
+			},
+			that: FilterV1{
+				Annotations:     []string{"a1", "b1"},
+				Containers:      []string{"a1", "b1"},
+				Controllers:     []string{"a1", "b1"},
+				ControllerKinds: []string{"a1", "b1"},
+				Clusters:        []string{"a1", "b1"},
+				Departments:     []string{"a1", "b1"},
+				Environments:    []string{"a1", "b1"},
+				Labels:          []string{"a1", "b1"},
+				Namespaces:      []string{"a1", "b1"},
+				Nodes:           []string{"a1", "b1"},
+				Owners:          []string{"a1", "b1"},
+				Pods:            []string{"a1", "b1"},
+				Products:        []string{"a1", "b1"},
+				Services:        []string{"a1", "b1"},
+				Teams:           []string{"a1", "b1"},
+			},
+			expected: false,
+		},
+		{
+			name: "one nil",
+			this: FilterV1{
+				Annotations:     []string{"x1", "b1"},
+				Containers:      []string{"a1", "b1"},
+				Controllers:     []string{"a1", "b1"},
+				ControllerKinds: []string{"a1", "b1"},
+				Clusters:        []string{"a1", "b1"},
+				Departments:     []string{"a1", "b1"},
+				Environments:    []string{"a1", "b1"},
+				Labels:          []string{"a1", "b1"},
+				Namespaces:      []string{"a1", "b1"},
+				Nodes:           []string{"a1", "b1"},
+				Owners:          []string{"a1", "b1"},
+				Pods:            []string{"a1", "b1"},
+				Products:        []string{"a1", "b1"},
+				Services:        []string{"a1", "b1"},
+				Teams:           []string{"a1", "b1"},
+			},
+			that: FilterV1{
+				Annotations:     nil,
+				Containers:      nil,
+				Controllers:     nil,
+				ControllerKinds: nil,
+				Clusters:        nil,
+				Departments:     nil,
+				Environments:    nil,
+				Labels:          nil,
+				Namespaces:      nil,
+				Nodes:           nil,
+				Owners:          nil,
+				Pods:            nil,
+				Products:        nil,
+				Services:        nil,
+				Teams:           nil,
+			},
+			expected: false,
+		},
+	}
+
+	for _, tc := range testCases {
+		got := tc.this.Equals(tc.that)
+		if got != tc.expected {
+			t.Fatalf("expected %t, got: %t for test case: %s", tc.expected, got, tc.name)
+		}
+	}
+}

+ 11 - 2
ui/src/Reports.js

@@ -17,6 +17,7 @@ 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' },
@@ -103,6 +104,7 @@ const ReportsPage = () => {
   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')
@@ -119,8 +121,7 @@ const ReportsPage = () => {
   const [fetch, setFetch] = useState(false)
   const [loading, setLoading] = useState(true)
   const [errors, setErrors] = useState([])
-  const [currency, setCurrency] = useState('USD')
-
+  
   // Initialize once, then fetch report each time setFetch(true) is called
   useEffect(() => {
     if (!init) {
@@ -139,6 +140,7 @@ const ReportsPage = () => {
     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() {
@@ -245,6 +247,13 @@ const ReportsPage = () => {
             title={title}
             cumulativeData={cumulativeData}
             currency={currency}
+            currencyOptions={currencyCodes}
+            setCurrency={(curr) => {
+              searchParams.set('currency', curr);
+              routerHistory.push({
+                search: `?${searchParams.toString()}`
+              });
+            }}
           />
         </div>
 

+ 15 - 0
ui/src/components/Controls/Edit.js

@@ -22,6 +22,7 @@ function EditControl({
   windowOptions, window, setWindow,
   aggregationOptions, aggregateBy, setAggregateBy,
   accumulateOptions, accumulate, setAccumulate,
+  currencyOptions, currency, setCurrency,
 }) {
   const classes = useStyles();
   return (
@@ -52,6 +53,20 @@ function EditControl({
           {accumulateOptions.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>
   );
 }

+ 4 - 0
ui/src/components/Controls/index.js

@@ -16,6 +16,8 @@ const Controls = ({
   title,
   cumulativeData,
   currency,
+  currencyOptions,
+  setCurrency,
 }) => {
 
   return (
@@ -31,6 +33,8 @@ const Controls = ({
         accumulate={accumulate}
         setAccumulate={setAccumulate}
         currency={currency}
+        currencyOptions={currencyOptions}
+        setCurrency={setCurrency}
       />
 
       <DownloadControl

+ 1 - 0
ui/src/constants/currencyCodes.js

@@ -0,0 +1 @@
+export const currencyCodes = ["AED", "AFN", "ALL", "AMD", "ANG", "AOA", "ARS", "AUD", "AWG", "AZN", "BAM", "BBD", "BDT", "BGN", "BHD", "BIF", "BMD", "BND", "BOB", "BOV", "BRL", "BSD", "BTN", "BWP", "BYR", "BZD", "CAD", "CDF", "CHE", "CHF", "CHW", "CLF", "CLP", "CNY", "COP", "COU", "CRC", "CUC", "CUP", "CVE", "CZK", "DJF", "DKK", "DOP", "DZD", "EGP", "ERN", "ETB", "EUR", "FJD", "FKP", "GBP", "GEL", "GHS", "GIP", "GMD", "GNF", "GTQ", "GYD", "HKD", "HNL", "HRK", "HTG", "HUF", "IDR", "ILS", "INR", "IQD", "IRR", "ISK", "JMD", "JOD", "JPY", "KES", "KGS", "KHR", "KMF", "KPW", "KRW", "KWD", "KYD", "KZT", "LAK", "LBP", "LKR", "LRD", "LSL", "LTL", "LVL", "LYD", "MAD", "MDL", "MGA", "MKD", "MMK", "MNT", "MOP", "MRO", "MUR", "MVR", "MWK", "MXN", "MXV", "MYR", "MZN", "NAD", "NGN", "NIO", "NOK", "NPR", "NZD", "OMR", "PAB", "PEN", "PGK", "PHP", "PKR", "PLN", "PYG", "QAR", "RON", "RSD", "RUB", "RWF", "SAR", "SBD", "SCR", "SDG", "SEK", "SGD", "SHP", "SLL", "SOS", "SRD", "SSP", "STD", "SYP", "SZL", "THB", "TJS", "TMT", "TND", "TOP", "TRY", "TTD", "TWD", "TZS", "UAH", "UGX", "USD", "USN", "USS", "UYI", "UYU", "UZS", "VEF", "VND", "VUV", "WST", "XAF", "XAG", "XAU", "XBA", "XBB", "XBC", "XBD", "XCD", "XDR", "XFU", "XOF", "XPD", "XPF", "XPT", "XTS", "XXX", "YER", "ZAR", "ZMW"];