Quellcode durchsuchen

implement autocomplete

Alex Meijer vor 2 Wochen
Ursprung
Commit
eaea494828

+ 160 - 0
pkg/allocation/autocompletequeryservice.go

@@ -0,0 +1,160 @@
+package allocation
+
+import (
+	"context"
+	"fmt"
+	"sort"
+	"strings"
+
+	"github.com/opencost/opencost/core/pkg/filter"
+	"github.com/opencost/opencost/core/pkg/opencost"
+)
+
+const DefaultAutocompleteResultLimit = 100
+const MaxAutocompleteResultLimit = 1000
+
+type AllocationAutocompleteRequest struct {
+	Search      string
+	Field       string
+	Limit       int
+	Window      opencost.Window
+	Filter      filter.Filter
+	LabelConfig *opencost.LabelConfig
+}
+
+type AllocationAutocompleteResponse struct {
+	Data []string `json:"data"`
+}
+
+type AutocompleteQueryService interface {
+	QueryAllocationAutocomplete(AllocationAutocompleteRequest, context.Context) (*AllocationAutocompleteResponse, error)
+}
+
+func QueryAllocationAutocompleteFromSetRange(asr *opencost.AllocationSetRange, req AllocationAutocompleteRequest) (*AllocationAutocompleteResponse, error) {
+	field, err := validateAutocompleteField(req.Field)
+	if err != nil {
+		return nil, fmt.Errorf("invalid field: %w", err)
+	}
+
+	limit := req.Limit
+	if limit <= 0 {
+		limit = DefaultAutocompleteResultLimit
+	}
+	if limit > MaxAutocompleteResultLimit {
+		return nil, fmt.Errorf("exceeded maxiumum autocomplete result limit of %d", MaxAutocompleteResultLimit)
+	}
+
+	var matcher opencost.AllocationMatcher
+	if req.Filter != nil {
+		compiler := opencost.NewAllocationMatchCompiler(req.LabelConfig)
+		matcher, err = compiler.Compile(req.Filter)
+		if err != nil {
+			return nil, fmt.Errorf("failed to compile filter: %w", err)
+		}
+	}
+
+	search := strings.ToLower(req.Search)
+	results := map[string]struct{}{}
+	for _, as := range asr.Slice() {
+		for _, alloc := range as.Allocations {
+			if alloc == nil || alloc.Properties == nil {
+				continue
+			}
+			if matcher != nil && !matcher.Matches(alloc) {
+				continue
+			}
+
+			values := allocationAutocompleteValues(alloc.Properties, field)
+			for _, value := range values {
+				if value == "" {
+					continue
+				}
+				if search != "" && !strings.Contains(strings.ToLower(value), search) {
+					continue
+				}
+				results[value] = struct{}{}
+			}
+		}
+	}
+
+	return &AllocationAutocompleteResponse{Data: uniqueSortedLimited(results, limit)}, nil
+}
+
+func validateAutocompleteField(field string) (string, error) {
+	if field == "" {
+		return "", fmt.Errorf("field is required")
+	}
+
+	f := strings.ToLower(field)
+	switch f {
+	case "cluster", "namespace", "node", "controllerkind", "controllername", "pod", "container", "account", "label", "namespacelabel":
+		return f, nil
+	}
+
+	if strings.HasPrefix(f, "label:") {
+		_, labelKey, _ := strings.Cut(field, ":")
+		return "label:" + labelKey, nil
+	}
+	if strings.HasPrefix(f, "namespacelabel:") {
+		_, labelKey, _ := strings.Cut(field, ":")
+		return "namespacelabel:" + labelKey, nil
+	}
+
+	return "", fmt.Errorf("unrecognized field: %s", field)
+}
+
+func allocationAutocompleteValues(props *opencost.AllocationProperties, field string) []string {
+	switch {
+	case field == "cluster":
+		return []string{props.Cluster}
+	case field == "namespace":
+		return []string{props.Namespace}
+	case field == "node":
+		return []string{props.Node}
+	case field == "controllerkind":
+		return []string{props.ControllerKind}
+	case field == "controllername":
+		return []string{props.Controller}
+	case field == "pod":
+		return []string{props.Pod}
+	case field == "container":
+		return []string{props.Container}
+	case field == "account":
+		return nil
+	case field == "label":
+		return mapKeys(props.Labels)
+	case strings.HasPrefix(strings.ToLower(field), "label:"):
+		label := strings.TrimPrefix(field, "label:")
+		if v, ok := props.Labels[label]; ok {
+			return []string{v}
+		}
+	case field == "namespacelabel":
+		return mapKeys(props.NamespaceLabels)
+	case strings.HasPrefix(strings.ToLower(field), "namespacelabel:"):
+		label := strings.TrimPrefix(field, "namespacelabel:")
+		if v, ok := props.NamespaceLabels[label]; ok {
+			return []string{v}
+		}
+	}
+	return nil
+}
+
+func mapKeys(values map[string]string) []string {
+	result := make([]string, 0, len(values))
+	for k := range values {
+		result = append(result, k)
+	}
+	return result
+}
+
+func uniqueSortedLimited(values map[string]struct{}, limit int) []string {
+	out := make([]string, 0, len(values))
+	for v := range values {
+		out = append(out, v)
+	}
+	sort.Strings(out)
+	if len(out) > limit {
+		return out[:limit]
+	}
+	return out
+}

+ 59 - 0
pkg/allocation/autocompletequeryservice_test.go

@@ -0,0 +1,59 @@
+package allocation
+
+import (
+	"testing"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/opencost"
+)
+
+func TestQueryAllocationAutocompleteFromSetRange(t *testing.T) {
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	as := opencost.NewAllocationSet(start, start.Add(24*time.Hour))
+	as.Set(opencost.NewMockUnitAllocation("a1", start, 24*time.Hour, &opencost.AllocationProperties{
+		Cluster:         "cluster-a",
+		Namespace:       "ns-a",
+		Pod:             "pod-a",
+		Container:       "container-a",
+		ControllerKind:  "deployment",
+		Controller:      "deploy-a",
+		Node:            "node-a",
+		Labels:          map[string]string{"team": "platform", "app": "api"},
+		NamespaceLabels: map[string]string{"owner": "sre"},
+	}))
+	as.Set(opencost.NewMockUnitAllocation("a2", start, 24*time.Hour, &opencost.AllocationProperties{
+		Cluster:         "cluster-b",
+		Namespace:       "ns-b",
+		Pod:             "pod-b",
+		Container:       "container-b",
+		ControllerKind:  "statefulset",
+		Controller:      "db-a",
+		Node:            "node-b",
+		Labels:          map[string]string{"team": "data", "app": "db"},
+		NamespaceLabels: map[string]string{"owner": "db"},
+	}))
+
+	asr := opencost.NewAllocationSetRange(as)
+
+	resp, err := QueryAllocationAutocompleteFromSetRange(asr, AllocationAutocompleteRequest{
+		Field: "label",
+		Limit: 10,
+	})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(resp.Data) != 2 || resp.Data[0] != "app" || resp.Data[1] != "team" {
+		t.Fatalf("unexpected label autocomplete response: %+v", resp.Data)
+	}
+
+	valueResp, err := QueryAllocationAutocompleteFromSetRange(asr, AllocationAutocompleteRequest{
+		Field:  "label:team",
+		Search: "plat",
+	})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(valueResp.Data) != 1 || valueResp.Data[0] != "platform" {
+		t.Fatalf("unexpected label value autocomplete response: %+v", valueResp.Data)
+	}
+}

+ 138 - 0
pkg/asset/autocompletequeryservice.go

@@ -0,0 +1,138 @@
+package asset
+
+import (
+	"context"
+	"fmt"
+	"sort"
+	"strings"
+
+	"github.com/opencost/opencost/core/pkg/filter"
+	"github.com/opencost/opencost/core/pkg/opencost"
+)
+
+const DefaultAutocompleteResultLimit = 100
+const MaxAutocompleteResultLimit = 1000
+
+type AssetAutocompleteRequest struct {
+	TenantID string
+	Search   string
+	Field    string
+	Limit    int
+	Window   opencost.Window
+	Filter   filter.Filter
+}
+
+type AssetAutocompleteResponse struct {
+	Data []string `json:"data"`
+}
+
+type AutocompleteQueryService interface {
+	QueryAssetAutocomplete(AssetAutocompleteRequest, context.Context) (*AssetAutocompleteResponse, error)
+}
+
+func QueryAssetAutocompleteFromSet(assetSet *opencost.AssetSet, req AssetAutocompleteRequest) (*AssetAutocompleteResponse, error) {
+	if req.TenantID == "" {
+		return nil, fmt.Errorf("tenant ID is required")
+	}
+
+	field, err := validateAutocompleteField(req.Field)
+	if err != nil {
+		return nil, fmt.Errorf("invalid field: %w", err)
+	}
+
+	limit := req.Limit
+	if limit <= 0 {
+		limit = DefaultAutocompleteResultLimit
+	}
+	if limit > MaxAutocompleteResultLimit {
+		return nil, fmt.Errorf("exceeded maxiumum autocomplete result limit of %d", MaxAutocompleteResultLimit)
+	}
+
+	var matcher opencost.AssetMatcher
+	if req.Filter != nil {
+		compiler := opencost.NewAssetMatchCompiler()
+		matcher, err = compiler.Compile(req.Filter)
+		if err != nil {
+			return nil, fmt.Errorf("failed to compile filter: %w", err)
+		}
+	}
+
+	search := strings.ToLower(req.Search)
+	results := map[string]struct{}{}
+	for _, a := range assetSet.Assets {
+		if a == nil {
+			continue
+		}
+		if matcher != nil && !matcher.Matches(a) {
+			continue
+		}
+
+		values := assetAutocompleteValues(a, field)
+		for _, value := range values {
+			if value == "" {
+				continue
+			}
+			if search != "" && !strings.Contains(strings.ToLower(value), search) {
+				continue
+			}
+			results[value] = struct{}{}
+		}
+	}
+
+	data := make([]string, 0, len(results))
+	for value := range results {
+		data = append(data, value)
+	}
+	sort.Strings(data)
+	if len(data) > limit {
+		data = data[:limit]
+	}
+	return &AssetAutocompleteResponse{Data: data}, nil
+}
+
+func validateAutocompleteField(field string) (string, error) {
+	f := strings.ToLower(field)
+	switch f {
+	case "account", "cluster", "name", "provider", "providerid", "type", "category":
+		return f, nil
+	}
+	if strings.HasPrefix(f, "label") {
+		return f, nil
+	}
+	return "", fmt.Errorf("unrecognized field: %s", field)
+}
+
+func assetAutocompleteValues(asset opencost.Asset, field string) []string {
+	props := asset.GetProperties()
+	if props == nil {
+		return nil
+	}
+	switch {
+	case field == "account":
+		return []string{props.Account}
+	case field == "cluster":
+		return []string{props.Cluster}
+	case field == "name":
+		return []string{props.Name}
+	case field == "provider":
+		return []string{props.Provider}
+	case field == "providerid":
+		return []string{props.ProviderID}
+	case field == "type":
+		return []string{asset.Type().String()}
+	case field == "category":
+		return []string{props.Category}
+	case field == "label":
+		keys := make([]string, 0, len(asset.GetLabels()))
+		for key := range asset.GetLabels() {
+			keys = append(keys, key)
+		}
+		return keys
+	case strings.HasPrefix(field, "label:"):
+		labelName := strings.TrimPrefix(field, "label:")
+		if value, ok := asset.GetLabels()[labelName]; ok {
+			return []string{value}
+		}
+	}
+	return nil
+}

+ 49 - 0
pkg/asset/autocompletequeryservice_test.go

@@ -0,0 +1,49 @@
+package asset
+
+import (
+	"testing"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/opencost"
+)
+
+func TestQueryAssetAutocompleteFromSet(t *testing.T) {
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := start.Add(24 * time.Hour)
+	window := opencost.NewWindow(&start, &end)
+
+	nodeA := opencost.NewNode("node-a", "cluster-a", "provider-a", start, end, window)
+	nodeA.SetLabels(map[string]string{"team": "platform", "app": "api"})
+	nodeA.GetProperties().Account = "acct-a"
+	nodeA.GetProperties().Category = opencost.ComputeCategory
+
+	nodeB := opencost.NewNode("node-b", "cluster-b", "provider-b", start, end, window)
+	nodeB.SetLabels(map[string]string{"team": "data", "app": "db"})
+	nodeB.GetProperties().Account = "acct-b"
+	nodeB.GetProperties().Category = opencost.ComputeCategory
+
+	assetSet := opencost.NewAssetSet(start, end, nodeA, nodeB)
+
+	resp, err := QueryAssetAutocompleteFromSet(assetSet, AssetAutocompleteRequest{
+		TenantID: "opencost",
+		Field:    "cluster",
+	})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(resp.Data) != 2 || resp.Data[0] != "cluster-a" || resp.Data[1] != "cluster-b" {
+		t.Fatalf("unexpected cluster autocomplete response: %+v", resp.Data)
+	}
+
+	labelResp, err := QueryAssetAutocompleteFromSet(assetSet, AssetAutocompleteRequest{
+		TenantID: "opencost",
+		Field:    "label:team",
+		Search:   "plat",
+	})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(labelResp.Data) != 1 || labelResp.Data[0] != "platform" {
+		t.Fatalf("unexpected label autocomplete response: %+v", labelResp.Data)
+	}
+}

+ 44 - 0
pkg/cloudcost/autocomplete_test.go

@@ -0,0 +1,44 @@
+package cloudcost
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/opencost"
+)
+
+func TestRepositoryQuerier_QueryCloudCostAutocomplete(t *testing.T) {
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := start.Add(24 * time.Hour)
+
+	repo := NewMemoryRepository()
+	ccs := DefaultMockCloudCostSet(start, end, "aws", "integration-1")
+	if err := repo.Put(ccs); err != nil {
+		t.Fatalf("failed to seed repository: %v", err)
+	}
+	rq := NewRepositoryQuerier(repo)
+
+	resp, err := rq.QueryCloudCostAutocomplete(context.Background(), CloudCostAutocompleteRequest{
+		Field:  opencost.CloudCostServiceProp,
+		Window: opencost.NewClosedWindow(start, end),
+	})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(resp.Data) != 2 {
+		t.Fatalf("expected 2 service values, got %d: %+v", len(resp.Data), resp.Data)
+	}
+
+	labelResp, err := rq.QueryCloudCostAutocomplete(context.Background(), CloudCostAutocompleteRequest{
+		Field:  "label:label1",
+		Search: "value1",
+		Window: opencost.NewClosedWindow(start, end),
+	})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(labelResp.Data) != 1 || labelResp.Data[0] != "value1" {
+		t.Fatalf("unexpected label autocomplete response: %+v", labelResp.Data)
+	}
+}

+ 16 - 0
pkg/cloudcost/querier.go

@@ -13,6 +13,7 @@ import (
 // Querier allows for querying ranges of CloudCost data
 type Querier interface {
 	Query(context.Context, QueryRequest) (*opencost.CloudCostSetRange, error)
+	QueryCloudCostAutocomplete(context.Context, CloudCostAutocompleteRequest) (*CloudCostAutocompleteResponse, error)
 }
 
 type QueryRequest struct {
@@ -23,6 +24,21 @@ type QueryRequest struct {
 	Filter      filter.Filter
 }
 
+const DefaultAutocompleteResultLimit = 100
+const MaxAutocompleteResultLimit = 1000
+
+type CloudCostAutocompleteRequest struct {
+	Search string
+	Field  string
+	Limit  int
+	Window opencost.Window
+	Filter filter.Filter
+}
+
+type CloudCostAutocompleteResponse struct {
+	Data []string `json:"data"`
+}
+
 // DefaultChartItemsLength the default max number of items for a ViewGraphDataSet
 const DefaultChartItemsLength int = 10
 

+ 33 - 0
pkg/cloudcost/queryservice.go

@@ -68,6 +68,39 @@ func (s *QueryService) GetCloudCostHandler() func(w http.ResponseWriter, r *http
 	}
 }
 
+func (s *QueryService) GetCloudCostAutocompleteHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+		tracer := otel.Tracer(tracerName)
+		ctx, span := tracer.Start(r.Context(), "Service.GetCloudCostAutocompleteHandler")
+		defer span.End()
+
+		if s == nil {
+			http.Error(w, "Query Service is nil", http.StatusNotImplemented)
+			return
+		}
+		if s.Querier == nil {
+			http.Error(w, "CloudCost Query Service is nil", http.StatusNotImplemented)
+			return
+		}
+
+		qp := httputil.NewQueryParams(r.URL.Query())
+		request, err := ParseCloudCostAutocompleteRequest(qp)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+
+		resp, err := s.Querier.QueryCloudCostAutocomplete(ctx, *request)
+		if err != nil {
+			http.Error(w, fmt.Sprintf("Internal server error: %s", err), http.StatusInternalServerError)
+			return
+		}
+
+		w.Header().Set("Content-Type", "application/json")
+		protocol.WriteData(w, resp)
+	}
+}
+
 func (s *QueryService) GetCloudCostViewGraphHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
 	// Return valid handler func
 	return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {

+ 37 - 0
pkg/cloudcost/queryservice_helper.go

@@ -64,6 +64,43 @@ func ParseCloudCostRequest(qp httputil.QueryParams) (*QueryRequest, error) {
 	return opts, nil
 }
 
+func ParseCloudCostAutocompleteRequest(qp httputil.QueryParams) (*CloudCostAutocompleteRequest, error) {
+	windowStr := qp.Get("window", "")
+	if windowStr == "" {
+		return nil, fmt.Errorf("missing require window param")
+	}
+
+	window, err := opencost.ParseWindowUTC(windowStr)
+	if err != nil {
+		return nil, fmt.Errorf("invalid window parameter: %w", err)
+	}
+	if window.IsOpen() {
+		return nil, fmt.Errorf("invalid window parameter: %s", window.String())
+	}
+
+	var parsedFilter filter.Filter
+	filterString := qp.Get("filter", "")
+	if filterString != "" {
+		parser := cloudcost.NewCloudCostFilterParser()
+		parsedFilter, err = parser.Parse(filterString)
+		if err != nil {
+			return nil, fmt.Errorf("Parsing 'filter' parameter: %s", err)
+		}
+	}
+
+	req := &CloudCostAutocompleteRequest{
+		Search: qp.Get("search", ""),
+		Field:  qp.Get("field", ""),
+		Limit:  qp.GetInt("limit", 0),
+		Window: window,
+		Filter: parsedFilter,
+	}
+	if req.Field == "" {
+		return nil, fmt.Errorf("missing required 'field' parameter")
+	}
+	return req, nil
+}
+
 func ParseCloudCostViewRequest(qp httputil.QueryParams) (*ViewQueryRequest, error) {
 	qr, err := ParseCloudCostRequest(qp)
 	if err != nil {

+ 87 - 0
pkg/cloudcost/repositoryquerier.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"fmt"
 	"sort"
+	"strings"
 
 	"github.com/opencost/opencost/core/pkg/log"
 	"github.com/opencost/opencost/core/pkg/opencost"
@@ -67,6 +68,92 @@ func (rq *RepositoryQuerier) Query(ctx context.Context, request QueryRequest) (*
 	return ccsr, nil
 }
 
+func (rq *RepositoryQuerier) QueryCloudCostAutocomplete(ctx context.Context, request CloudCostAutocompleteRequest) (*CloudCostAutocompleteResponse, error) {
+	if request.Window.IsOpen() {
+		return nil, fmt.Errorf("invalid window for autocomplete query: %s", request.Window.String())
+	}
+
+	limit := request.Limit
+	if limit <= 0 {
+		limit = DefaultAutocompleteResultLimit
+	}
+	if limit > MaxAutocompleteResultLimit {
+		return nil, fmt.Errorf("exceeded maxiumum autocomplete result limit of %d", MaxAutocompleteResultLimit)
+	}
+
+	ccsr, err := rq.Query(ctx, QueryRequest{
+		Start:      *request.Window.Start(),
+		End:        *request.Window.End(),
+		Accumulate: opencost.AccumulateOptionNone,
+		Filter:     request.Filter,
+	})
+	if err != nil {
+		return nil, fmt.Errorf("QueryCloudCostAutocomplete: query failed: %w", err)
+	}
+
+	prop := strings.ToLower(request.Field)
+	search := strings.ToLower(request.Search)
+	results := map[string]struct{}{}
+	for _, ccs := range ccsr.CloudCostSets {
+		for _, cc := range ccs.CloudCosts {
+			if cc == nil || cc.Properties == nil {
+				continue
+			}
+
+			values := cloudCostAutocompleteValues(cc, prop)
+			for _, value := range values {
+				if value == "" {
+					continue
+				}
+				if search != "" && !strings.Contains(strings.ToLower(value), search) {
+					continue
+				}
+				results[value] = struct{}{}
+			}
+		}
+	}
+
+	data := make([]string, 0, len(results))
+	for result := range results {
+		data = append(data, result)
+	}
+	sort.Strings(data)
+	if len(data) > limit {
+		data = data[:limit]
+	}
+
+	return &CloudCostAutocompleteResponse{Data: data}, nil
+}
+
+func cloudCostAutocompleteValues(cc *opencost.CloudCost, field string) []string {
+	if field == "label" {
+		keys := make([]string, 0, len(cc.Properties.Labels))
+		for label := range cc.Properties.Labels {
+			keys = append(keys, label)
+		}
+		return keys
+	}
+	if strings.HasPrefix(field, "label:") {
+		labelName := strings.TrimPrefix(field, "label:")
+		if value, ok := cc.Properties.Labels[labelName]; ok {
+			return []string{value}
+		}
+		return nil
+	}
+
+	property, err := opencost.ParseCloudCostProperty(field)
+	if err != nil {
+		return nil
+	}
+
+	value, err := cc.StringProperty(string(property))
+	if err != nil {
+		return nil
+	}
+
+	return []string{value}
+}
+
 func (rq *RepositoryQuerier) QueryViewGraph(ctx context.Context, request ViewQueryRequest) (ViewGraphData, error) {
 	ccasr, err := rq.Query(ctx, request.QueryRequest)
 	if err != nil {

+ 107 - 0
pkg/costmodel/autocomplete.go

@@ -0,0 +1,107 @@
+package costmodel
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"github.com/julienschmidt/httprouter"
+	"github.com/opencost/opencost/core/pkg/filter"
+	allocationfilter "github.com/opencost/opencost/core/pkg/filter/allocation"
+	assetfilter "github.com/opencost/opencost/core/pkg/filter/asset"
+	"github.com/opencost/opencost/core/pkg/opencost"
+	"github.com/opencost/opencost/core/pkg/util/httputil"
+	"github.com/opencost/opencost/pkg/allocation"
+	"github.com/opencost/opencost/pkg/asset"
+	"github.com/opencost/opencost/pkg/env"
+)
+
+func (a *Accesses) ComputeAllocationAutocompleteHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	w.Header().Set("Content-Type", "application/json")
+	qp := httputil.NewQueryParams(r.URL.Query())
+
+	window, err := opencost.ParseWindowWithOffset(qp.Get("window", ""), env.GetParsedUTCOffset())
+	if err != nil {
+		http.Error(w, fmt.Sprintf("Invalid 'window' parameter: %s", err), http.StatusBadRequest)
+		return
+	}
+
+	var parsedFilter filter.Filter
+	filterString := qp.Get("filter", "")
+	if filterString != "" {
+		parser := allocationfilter.NewAllocationFilterParser()
+		parsedFilter, err = parser.Parse(filterString)
+		if err != nil {
+			http.Error(w, fmt.Sprintf("Invalid 'filter' parameter: %s", err), http.StatusBadRequest)
+			return
+		}
+	}
+
+	resp, err := a.QueryAllocationAutocomplete(allocation.AllocationAutocompleteRequest{
+		Search:      qp.Get("search", ""),
+		Field:       qp.Get("field", ""),
+		Limit:       qp.GetInt("limit", 0),
+		Window:      window,
+		Filter:      parsedFilter,
+		LabelConfig: opencost.NewLabelConfig(),
+	}, r.Context())
+	if err != nil {
+		http.Error(w, fmt.Sprintf("Error getting allocation autocomplete: %s", err), http.StatusInternalServerError)
+		return
+	}
+
+	WriteData(w, resp, nil)
+}
+
+func (a *Accesses) QueryAllocationAutocomplete(req allocation.AllocationAutocompleteRequest, ctx context.Context) (*allocation.AllocationAutocompleteResponse, error) {
+	asr, err := a.Model.QueryAllocation(req.Window, req.Window.Duration(), nil, false, false, false, false, false, opencost.AccumulateOptionNone, false, "")
+	if err != nil {
+		return nil, fmt.Errorf("error querying allocations: %w", err)
+	}
+	return allocation.QueryAllocationAutocompleteFromSetRange(asr, req)
+}
+
+func (a *Accesses) ComputeAssetsAutocompleteHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	w.Header().Set("Content-Type", "application/json")
+	qp := httputil.NewQueryParams(r.URL.Query())
+
+	window, err := opencost.ParseWindowWithOffset(qp.Get("window", ""), env.GetParsedUTCOffset())
+	if err != nil {
+		http.Error(w, fmt.Sprintf("Invalid 'window' parameter: %s", err), http.StatusBadRequest)
+		return
+	}
+
+	var parsedFilter filter.Filter
+	filterString := qp.Get("filter", "")
+	if filterString != "" {
+		parser := assetfilter.NewAssetFilterParser()
+		parsedFilter, err = parser.Parse(filterString)
+		if err != nil {
+			http.Error(w, fmt.Sprintf("Invalid 'filter' parameter: %s", err), http.StatusBadRequest)
+			return
+		}
+	}
+
+	resp, err := a.QueryAssetAutocomplete(asset.AssetAutocompleteRequest{
+		TenantID: qp.Get("tenantId", "opencost"),
+		Search:   qp.Get("search", ""),
+		Field:    qp.Get("field", ""),
+		Limit:    qp.GetInt("limit", 0),
+		Window:   window,
+		Filter:   parsedFilter,
+	}, r.Context())
+	if err != nil {
+		http.Error(w, fmt.Sprintf("Error getting asset autocomplete: %s", err), http.StatusInternalServerError)
+		return
+	}
+
+	WriteData(w, resp, nil)
+}
+
+func (a *Accesses) QueryAssetAutocomplete(req asset.AssetAutocompleteRequest, ctx context.Context) (*asset.AssetAutocompleteResponse, error) {
+	assetSet, err := a.Model.ComputeAssets(*req.Window.Start(), *req.Window.End())
+	if err != nil {
+		return nil, fmt.Errorf("error computing assets: %w", err)
+	}
+	return asset.QueryAssetAutocompleteFromSet(assetSet, req)
+}

+ 3 - 0
pkg/costmodel/router.go

@@ -556,6 +556,8 @@ func Initialize(router *httprouter.Router, additionalConfigWatchers ...*watcher.
 	router.GET("/costDataModel", a.CostDataModel)
 	router.GET("/allocation/compute", a.ComputeAllocationHandler)
 	router.GET("/allocation/compute/summary", a.ComputeAllocationHandlerSummary)
+	router.GET("/allocation/autocomplete", a.ComputeAllocationAutocompleteHandler)
+	router.GET("/assets/autocomplete", a.ComputeAssetsAutocompleteHandler)
 	router.GET("/allNodePricing", a.GetAllNodePricing)
 	router.POST("/refreshPricing", a.RefreshPricingData)
 	router.GET("/managementPlatform", a.ManagementPlatform)
@@ -617,6 +619,7 @@ func InitializeCloudCost(router *httprouter.Router) *cloudcost.PipelineService {
 	cloudCostQueryService := cloudcost.NewQueryService(repoQuerier, repoQuerier)
 
 	router.GET("/cloudCost", cloudCostQueryService.GetCloudCostHandler())
+	router.GET("/cloudCost/autocomplete", cloudCostQueryService.GetCloudCostAutocompleteHandler())
 	router.GET("/cloudCost/view/graph", cloudCostQueryService.GetCloudCostViewGraphHandler())
 	router.GET("/cloudCost/view/totals", cloudCostQueryService.GetCloudCostViewTotalsHandler())
 	router.GET("/cloudCost/view/table", cloudCostQueryService.GetCloudCostViewTableHandler(nil))