diff --git a/config/sample-config.yaml b/config/sample-config.yaml index 62d946d75..4049a10a3 100644 --- a/config/sample-config.yaml +++ b/config/sample-config.yaml @@ -480,46 +480,55 @@ frontend: - id: K8S_Name name: Names calculated: '[SrcK8S_Name,DstK8S_Name]' + filter: name default: false width: 15 - id: K8S_Type name: Kinds calculated: '[SrcK8S_Type,DstK8S_Type]' + filter: kind default: false width: 10 - id: K8S_OwnerName name: Owners calculated: '[SrcK8S_OwnerName,DstK8S_OwnerName]' + filter: owner_name default: false width: 15 - id: K8S_OwnerType name: Owner Kinds calculated: '[SrcK8S_OwnerType,DstK8S_OwnerType]' + filter: kind default: false width: 10 - id: K8S_Namespace name: Namespaces calculated: '[SrcK8S_Namespace,DstK8S_Namespace]' + filter: namespace default: false width: 15 - id: Addr name: IP calculated: '[SrcAddr,DstAddr]' + filter: address default: false width: 10 - id: Port name: Ports calculated: '[SrcPort,DstPort]' + filter: port default: false width: 10 - id: Mac name: MAC calculated: '[SrcMac,DstMac]' + filter: mac default: false width: 10 - id: K8S_HostIP name: Node IP calculated: '[SrcK8S_HostIP,DstK8S_HostIP]' + filter: host_address default: false width: 10 - id: Sampling @@ -530,16 +539,19 @@ frontend: - id: K8S_HostName name: Node Name calculated: '[SrcK8S_HostName,DstK8S_HostName]' + filter: host_name default: false width: 15 - id: K8S_Object name: Kubernetes Objects calculated: '[column.SrcK8S_Object,column.DstK8S_Object]' + filter: resource default: false width: 15 - id: K8S_OwnerObject name: Owner Kubernetes Objects calculated: '[column.SrcK8S_OwnerObject,column.DstK8S_OwnerObject]' + filter: resource default: false width: 15 - id: K8S_FlowLayer @@ -822,6 +834,22 @@ frontend: name: Cluster component: autocomplete hint: Specify a cluster ID or name. + - id: namespace + name: Namespace + component: autocomplete + autoCompleteAddsQuotes: true + category: targeteable + placeholder: 'E.g: netobserv' + hint: Specify a single kubernetes name. + examples: |- + Specify a single kubernetes name following these rules: + - Containing any alphanumeric, hyphen, underscrore or dot character + - Partial text like cluster, cluster-image, image-registry + - Exact match using quotes like "cluster-image-registry" + - Case sensitive match using quotes like "Deployment" + - Starting text like cluster, "cluster-*" + - Ending text like "*-registry" + - Pattern like "cluster-*-registry", "c*-*-r*y", -i*e- - id: src_namespace name: Namespace component: autocomplete @@ -854,6 +882,21 @@ frontend: - Starting text like cluster, "cluster-*" - Ending text like "*-registry" - Pattern like "cluster-*-registry", "c*-*-r*y", -i*e- + - id: name + name: Name + component: text + category: targeteable + placeholder: 'E.g: my-pod' + hint: Specify a single kubernetes name. + examples: |- + Specify a single kubernetes name following these rules: + - Containing any alphanumeric, hyphen, underscrore or dot character + - Partial text like cluster, cluster-image, image-registry + - Exact match using quotes like "cluster-image-registry" + - Case sensitive match using quotes like "Deployment" + - Starting text like cluster, "cluster-*" + - Ending text like "*-registry" + - Pattern like "cluster-*-registry", "c*-*-r*y", -i*e- - id: src_name name: Name component: text @@ -884,6 +927,12 @@ frontend: - Starting text like cluster, "cluster-*" - Ending text like "*-registry" - Pattern like "cluster-*-registry", "c*-*-r*y", -i*e- + - id: kind + name: Kind + component: autocomplete + autoCompleteAddsQuotes: true + category: targeteable + placeholder: 'E.g: Pod, Service' - id: src_kind name: Kind component: autocomplete @@ -896,6 +945,21 @@ frontend: autoCompleteAddsQuotes: true category: destination placeholder: 'E.g: Pod, Service' + - id: owner_name + name: Owner Name + component: text + category: targeteable + placeholder: 'E.g: my-deployment' + hint: Specify a single kubernetes name. + examples: |- + Specify a single kubernetes name following these rules: + - Containing any alphanumeric, hyphen, underscrore or dot character + - Partial text like cluster, cluster-image, image-registry + - Exact match using quotes like "cluster-image-registry" + - Case sensitive match using quotes like "Deployment" + - Starting text like cluster, "cluster-*" + - Ending text like "*-registry" + - Pattern like "cluster-*-registry", "c*-*-r*y", -i*e- - id: src_owner_name name: Owner Name component: text @@ -926,6 +990,11 @@ frontend: - Starting text like cluster, "cluster-*" - Ending text like "*-registry" - Pattern like "cluster-*-registry", "c*-*-r*y", -i*e- + - id: zone + name: Zone + component: autocomplete + category: targeteable + hint: Specify a single zone. - id: src_zone name: Zone component: autocomplete @@ -936,6 +1005,11 @@ frontend: component: autocomplete category: destination hint: Specify a single zone. + - id: subnet_label + name: Subnet Label + component: autocomplete + category: targeteable + hint: Specify a subnet label, or an empty string to get unmatched sources. - id: src_subnet_label name: Subnet Label component: autocomplete @@ -946,6 +1020,17 @@ frontend: component: autocomplete category: destination hint: Specify a subnet label, or an empty string to get unmatched destinations. + - id: resource + name: Resource + component: autocomplete + category: targeteable + placeholder: 'E.g: Deployment.example.my-dep or Pod.default.my-pod' + hint: Specify an existing resource from its kind, namespace and name. + examples: |- + Specify a kind, namespace and name from existing: + - Select kind first from suggestions + - Then select namespace from suggestions + - Finally select name from suggestions - id: src_resource name: Resource component: autocomplete @@ -968,6 +1053,17 @@ frontend: - Select kind first from suggestions - Then select namespace from suggestions - Finally select name from suggestions + - id: address + name: IP + component: text + category: targeteable + hint: Specify a single IP or range. + placeholder: 'E.g: 192.0.2.0' + examples: |- + Specify IP following one of these rules: + - A single IPv4 or IPv6 address like 192.0.2.0, ::1 + - An IP address range like 192.168.0.1-192.189.10.12, 2001:db8::1-2001:db8::8 + - A CIDR specification like 192.51.100.0/24, 2001:db8::/32 - id: src_address name: IP component: text @@ -990,6 +1086,17 @@ frontend: - A single IPv4 or IPv6 address like 192.0.2.0, ::1 - An IP address range like 192.168.0.1-192.189.10.12, 2001:db8::1-2001:db8::8 - A CIDR specification like 192.51.100.0/24, 2001:db8::/32 + - id: port + name: Port + component: autocomplete + category: targeteable + hint: Specify a single port number or name. + placeholder: 'E.g: 80' + examples: |- + Specify a single port following one of these rules: + - A port number like 80, 21 + - A IANA name like HTTP, FTP + docUrl: https://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml - id: src_port name: Port component: autocomplete @@ -1012,6 +1119,12 @@ frontend: - A port number like 80, 21 - A IANA name like HTTP, FTP docUrl: https://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml + - id: mac + name: MAC + component: text + category: targeteable + placeholder: 'E.g: 42:01:0A:00:00:01' + hint: Specify a single MAC address. - id: src_mac name: MAC component: text @@ -1024,6 +1137,17 @@ frontend: category: destination placeholder: 'E.g: 42:01:0A:00:00:01' hint: Specify a single MAC address. + - id: host_address + name: Node IP + component: text + category: targeteable + placeholder: 'E.g: 10.0.0.1' + hint: Specify a single IP or range. + examples: |- + Specify IP following one of these rules: + - A single IPv4 or IPv6 address like 192.0.2.0, ::1 + - An IP address range like 192.168.0.1-192.189.10.12, 2001:db8::1-2001:db8::8 + - A CIDR specification like 192.51.100.0/24, 2001:db8::/32 - id: src_host_address name: Node IP component: text @@ -1046,6 +1170,21 @@ frontend: - A single IPv4 or IPv6 address like 192.0.2.0, ::1 - An IP address range like 192.168.0.1-192.189.10.12, 2001:db8::1-2001:db8::8 - A CIDR specification like 192.51.100.0/24, 2001:db8::/32 + - id: host_name + name: Node Name + component: text + category: targeteable + placeholder: 'E.g: my-node' + hint: Specify a single kubernetes name. + examples: |- + Specify a single kubernetes name following these rules: + - Containing any alphanumeric, hyphen, underscrore or dot character + - Partial text like cluster, cluster-image, image-registry + - Exact match using quotes like "cluster-image-registry" + - Case sensitive match using quotes like "Deployment" + - Starting text like cluster, "cluster-*" + - Ending text like "*-registry" + - Pattern like "cluster-*-registry", "c*-*-r*y", -i*e- - id: src_host_name name: Node Name component: text @@ -1076,6 +1215,10 @@ frontend: - Starting text like cluster, "cluster-*" - Ending text like "*-registry" - Pattern like "cluster-*-registry", "c*-*-r*y", -i*e- + - id: network + name: Network Name + component: text + category: targeteable - id: src_network name: Network Name component: text diff --git a/pkg/handler/flows.go b/pkg/handler/flows.go index 2132b6471..8b13379b3 100644 --- a/pkg/handler/flows.go +++ b/pkg/handler/flows.go @@ -87,8 +87,8 @@ func (h *Handlers) getFlows(ctx context.Context, lokiClient httpclient.Caller, p // TODO: this should actually be managed from the loki gateway, with "namespace" query param filterGroups = filterGroups.Distribute( []filters.SingleQuery{ - {filters.NewMatch(fields.SrcNamespace, `"`+namespace+`"`)}, - {filters.NewMatch(fields.DstNamespace, `"`+namespace+`"`)}, + {filters.NewEqualMatch(fields.SrcNamespace, namespace)}, + {filters.NewEqualMatch(fields.DstNamespace, namespace)}, }, func(_ filters.SingleQuery) bool { return false }, ) diff --git a/pkg/handler/resources.go b/pkg/handler/resources.go index 8bf0d9295..6a1b51651 100644 --- a/pkg/handler/resources.go +++ b/pkg/handler/resources.go @@ -233,14 +233,14 @@ func (h *Handlers) GetNames(ctx context.Context) func(w http.ResponseWriter, r * func (h *Handlers) getNamesForPrefix(ctx context.Context, cl clients, prefix, kind, namespace string) ([]string, int, error) { filts := filters.SingleQuery{} if namespace != "" { - filts = append(filts, filters.NewMatch(prefix+fields.Namespace, exact(namespace))) + filts = append(filts, filters.NewRegexMatch(prefix+fields.Namespace, exact(namespace))) } var searchField string if utils.IsOwnerKind(kind) { - filts = append(filts, filters.NewMatch(prefix+fields.OwnerType, exact(kind))) + filts = append(filts, filters.NewRegexMatch(prefix+fields.OwnerType, exact(kind))) searchField = prefix + fields.OwnerName } else { - filts = append(filts, filters.NewMatch(prefix+fields.Type, exact(kind))) + filts = append(filts, filters.NewRegexMatch(prefix+fields.Type, exact(kind))) searchField = prefix + fields.Name } diff --git a/pkg/handler/topology.go b/pkg/handler/topology.go index bf5b17ea8..27e67b02f 100644 --- a/pkg/handler/topology.go +++ b/pkg/handler/topology.go @@ -231,11 +231,11 @@ func expandQueries(queries filters.MultiQueries, namespace string, isForProm fun // (Note that we use DstOwnerName both as an optimization as it's a Loki index, // and as convenience because looking for empty fields won't work if they aren't indexed) q1 := filters.SingleQuery{ - filters.NewMatch(fields.FlowDirection, `"`+string(constants.Ingress)+`","`+string(constants.Inner)+`"`), + filters.NewRegexMatch(fields.FlowDirection, `"`+string(constants.Ingress)+`","`+string(constants.Inner)+`"`), } q2 := filters.SingleQuery{ - filters.NewMatch(fields.FlowDirection, `"`+string(constants.Egress)+`"`), - filters.NewMatch(fields.DstType, `"","Service"`), + filters.NewRegexMatch(fields.FlowDirection, `"`+string(constants.Egress)+`"`), + filters.NewRegexMatch(fields.DstType, `"","Service"`), } shouldSkip := func(q filters.SingleQuery) bool { @@ -258,8 +258,8 @@ func expandQueries(queries filters.MultiQueries, namespace string, isForProm fun // TODO: this should actually be managed from the loki gateway, with "namespace" query param expanded = expanded.Distribute( []filters.SingleQuery{ - {filters.NewMatch(fields.SrcNamespace, `"`+namespace+`"`)}, - {filters.NewMatch(fields.DstNamespace, `"`+namespace+`"`)}, + {filters.NewRegexMatch(fields.SrcNamespace, `"`+namespace+`"`)}, + {filters.NewRegexMatch(fields.DstNamespace, `"`+namespace+`"`)}, }, isForProm, ) diff --git a/pkg/handler/topology_test.go b/pkg/handler/topology_test.go index a4ffc1131..951eed085 100644 --- a/pkg/handler/topology_test.go +++ b/pkg/handler/topology_test.go @@ -9,110 +9,110 @@ import ( ) func TestSplitForReportersMerge_NoSplit(t *testing.T) { - mq := filters.MultiQueries{filters.SingleQuery{filters.NewMatch("srcns", "a"), filters.NewMatch("FlowDirection", string(constants.Ingress))}} + mq := filters.MultiQueries{filters.SingleQuery{filters.NewRegexMatch("srcns", "a"), filters.NewRegexMatch("FlowDirection", string(constants.Ingress))}} res := expandQueries(mq, "", func(filters.SingleQuery) bool { return false }) assert.Len(t, res, 1) assert.Equal(t, filters.SingleQuery{ - filters.NewMatch("srcns", "a"), - filters.NewMatch("FlowDirection", string(constants.Ingress)), + filters.NewRegexMatch("srcns", "a"), + filters.NewRegexMatch("FlowDirection", string(constants.Ingress)), }, res[0]) } func TestSplitForReportersMerge(t *testing.T) { - mq := filters.MultiQueries{filters.SingleQuery{filters.NewMatch("srcns", "a"), filters.NewMatch("dstns", "b")}} + mq := filters.MultiQueries{filters.SingleQuery{filters.NewRegexMatch("srcns", "a"), filters.NewRegexMatch("dstns", "b")}} res := expandQueries(mq, "", func(filters.SingleQuery) bool { return false }) assert.Len(t, res, 2) assert.Equal(t, filters.SingleQuery{ - filters.NewMatch("FlowDirection", `"`+string(constants.Ingress)+`","`+string(constants.Inner)+`"`), - filters.NewMatch("srcns", "a"), - filters.NewMatch("dstns", "b"), + filters.NewRegexMatch("FlowDirection", `"`+string(constants.Ingress)+`","`+string(constants.Inner)+`"`), + filters.NewRegexMatch("srcns", "a"), + filters.NewRegexMatch("dstns", "b"), }, res[0]) assert.Equal(t, filters.SingleQuery{ - filters.NewMatch("FlowDirection", `"`+string(constants.Egress)+`"`), - filters.NewMatch("DstK8S_Type", `"","Service"`), - filters.NewMatch("srcns", "a"), - filters.NewMatch("dstns", "b"), + filters.NewRegexMatch("FlowDirection", `"`+string(constants.Egress)+`"`), + filters.NewRegexMatch("DstK8S_Type", `"","Service"`), + filters.NewRegexMatch("srcns", "a"), + filters.NewRegexMatch("dstns", "b"), }, res[1]) } func TestExpand_ComplexQuery(t *testing.T) { mq := filters.MultiQueries{ - filters.SingleQuery{filters.NewMatch("key1", "a"), filters.NewMatch("FlowDirection", string(constants.Ingress))}, - filters.SingleQuery{filters.NewMatch("key1", "a"), filters.NewMatch("key2", "b")}, - filters.SingleQuery{filters.NewMatch("prom-handled", "a")}, - filters.SingleQuery{filters.NewMatch("key1", "c"), filters.NewMatch("key2", "d")}, + filters.SingleQuery{filters.NewRegexMatch("key1", "a"), filters.NewRegexMatch("FlowDirection", string(constants.Ingress))}, + filters.SingleQuery{filters.NewRegexMatch("key1", "a"), filters.NewRegexMatch("key2", "b")}, + filters.SingleQuery{filters.NewRegexMatch("prom-handled", "a")}, + filters.SingleQuery{filters.NewRegexMatch("key1", "c"), filters.NewRegexMatch("key2", "d")}, } res := expandQueries(mq, "my-namespace", func(q filters.SingleQuery) bool { return q[0].Key == "prom-handled" }) assert.Len(t, res, 11) // First is unchanged for reporters, because FlowDirection is forced, but namespaces are injected assert.Equal(t, filters.SingleQuery{ - filters.NewMatch("SrcK8S_Namespace", `"my-namespace"`), - filters.NewMatch("key1", "a"), - filters.NewMatch("FlowDirection", string(constants.Ingress)), + filters.NewRegexMatch("SrcK8S_Namespace", `"my-namespace"`), + filters.NewRegexMatch("key1", "a"), + filters.NewRegexMatch("FlowDirection", string(constants.Ingress)), }, res[0]) assert.Equal(t, filters.SingleQuery{ - filters.NewMatch("DstK8S_Namespace", `"my-namespace"`), - filters.NewMatch("key1", "a"), - filters.NewMatch("FlowDirection", string(constants.Ingress)), + filters.NewRegexMatch("DstK8S_Namespace", `"my-namespace"`), + filters.NewRegexMatch("key1", "a"), + filters.NewRegexMatch("FlowDirection", string(constants.Ingress)), }, res[1]) // Second is expanded into 3rd+4th+5th+6th assert.Equal(t, filters.SingleQuery{ - filters.NewMatch("SrcK8S_Namespace", `"my-namespace"`), - filters.NewMatch("FlowDirection", `"`+string(constants.Ingress)+`","`+string(constants.Inner)+`"`), - filters.NewMatch("key1", "a"), - filters.NewMatch("key2", "b"), + filters.NewRegexMatch("SrcK8S_Namespace", `"my-namespace"`), + filters.NewRegexMatch("FlowDirection", `"`+string(constants.Ingress)+`","`+string(constants.Inner)+`"`), + filters.NewRegexMatch("key1", "a"), + filters.NewRegexMatch("key2", "b"), }, res[2]) assert.Equal(t, filters.SingleQuery{ - filters.NewMatch("DstK8S_Namespace", `"my-namespace"`), - filters.NewMatch("FlowDirection", `"`+string(constants.Ingress)+`","`+string(constants.Inner)+`"`), - filters.NewMatch("key1", "a"), - filters.NewMatch("key2", "b"), + filters.NewRegexMatch("DstK8S_Namespace", `"my-namespace"`), + filters.NewRegexMatch("FlowDirection", `"`+string(constants.Ingress)+`","`+string(constants.Inner)+`"`), + filters.NewRegexMatch("key1", "a"), + filters.NewRegexMatch("key2", "b"), }, res[3]) assert.Equal(t, filters.SingleQuery{ - filters.NewMatch("SrcK8S_Namespace", `"my-namespace"`), - filters.NewMatch("FlowDirection", `"`+string(constants.Egress)+`"`), - filters.NewMatch("DstK8S_Type", `"","Service"`), - filters.NewMatch("key1", "a"), - filters.NewMatch("key2", "b"), + filters.NewRegexMatch("SrcK8S_Namespace", `"my-namespace"`), + filters.NewRegexMatch("FlowDirection", `"`+string(constants.Egress)+`"`), + filters.NewRegexMatch("DstK8S_Type", `"","Service"`), + filters.NewRegexMatch("key1", "a"), + filters.NewRegexMatch("key2", "b"), }, res[4]) assert.Equal(t, filters.SingleQuery{ - filters.NewMatch("DstK8S_Namespace", `"my-namespace"`), - filters.NewMatch("FlowDirection", `"`+string(constants.Egress)+`"`), - filters.NewMatch("DstK8S_Type", `"","Service"`), - filters.NewMatch("key1", "a"), - filters.NewMatch("key2", "b"), + filters.NewRegexMatch("DstK8S_Namespace", `"my-namespace"`), + filters.NewRegexMatch("FlowDirection", `"`+string(constants.Egress)+`"`), + filters.NewRegexMatch("DstK8S_Type", `"","Service"`), + filters.NewRegexMatch("key1", "a"), + filters.NewRegexMatch("key2", "b"), }, res[5]) // Third is unchanged, because it's prom-handled assert.Equal(t, filters.SingleQuery{ - filters.NewMatch("prom-handled", "a"), + filters.NewRegexMatch("prom-handled", "a"), }, res[6]) // Fourth is expanded into 8th+9th+10th+11th assert.Equal(t, filters.SingleQuery{ - filters.NewMatch("SrcK8S_Namespace", `"my-namespace"`), - filters.NewMatch("FlowDirection", `"`+string(constants.Ingress)+`","`+string(constants.Inner)+`"`), - filters.NewMatch("key1", "c"), - filters.NewMatch("key2", "d"), + filters.NewRegexMatch("SrcK8S_Namespace", `"my-namespace"`), + filters.NewRegexMatch("FlowDirection", `"`+string(constants.Ingress)+`","`+string(constants.Inner)+`"`), + filters.NewRegexMatch("key1", "c"), + filters.NewRegexMatch("key2", "d"), }, res[7]) assert.Equal(t, filters.SingleQuery{ - filters.NewMatch("DstK8S_Namespace", `"my-namespace"`), - filters.NewMatch("FlowDirection", `"`+string(constants.Ingress)+`","`+string(constants.Inner)+`"`), - filters.NewMatch("key1", "c"), - filters.NewMatch("key2", "d"), + filters.NewRegexMatch("DstK8S_Namespace", `"my-namespace"`), + filters.NewRegexMatch("FlowDirection", `"`+string(constants.Ingress)+`","`+string(constants.Inner)+`"`), + filters.NewRegexMatch("key1", "c"), + filters.NewRegexMatch("key2", "d"), }, res[8]) assert.Equal(t, filters.SingleQuery{ - filters.NewMatch("SrcK8S_Namespace", `"my-namespace"`), - filters.NewMatch("FlowDirection", `"`+string(constants.Egress)+`"`), - filters.NewMatch("DstK8S_Type", `"","Service"`), - filters.NewMatch("key1", "c"), - filters.NewMatch("key2", "d"), + filters.NewRegexMatch("SrcK8S_Namespace", `"my-namespace"`), + filters.NewRegexMatch("FlowDirection", `"`+string(constants.Egress)+`"`), + filters.NewRegexMatch("DstK8S_Type", `"","Service"`), + filters.NewRegexMatch("key1", "c"), + filters.NewRegexMatch("key2", "d"), }, res[9]) assert.Equal(t, filters.SingleQuery{ - filters.NewMatch("DstK8S_Namespace", `"my-namespace"`), - filters.NewMatch("FlowDirection", `"`+string(constants.Egress)+`"`), - filters.NewMatch("DstK8S_Type", `"","Service"`), - filters.NewMatch("key1", "c"), - filters.NewMatch("key2", "d"), + filters.NewRegexMatch("DstK8S_Namespace", `"my-namespace"`), + filters.NewRegexMatch("FlowDirection", `"`+string(constants.Egress)+`"`), + filters.NewRegexMatch("DstK8S_Type", `"","Service"`), + filters.NewRegexMatch("key1", "c"), + filters.NewRegexMatch("key2", "d"), }, res[10]) } diff --git a/pkg/loki/flow_query.go b/pkg/loki/flow_query.go index b01aa0ea3..5ccf8fd7d 100644 --- a/pkg/loki/flow_query.go +++ b/pkg/loki/flow_query.go @@ -114,26 +114,28 @@ func (q *FlowQueryBuilder) addFilter(filter filters.Match) error { } else if q.config.IsIP(filter.Key) { q.addIPFilters(filter.Key, values, filter.Not) } else { - q.addLineFilters(filter.Key, values, filter.Not, filter.MoreThanOrEqual) + q.addLineFilters(filter, values) } return nil } -func (q *FlowQueryBuilder) addLineFilters(key string, values []string, not bool, moreThan bool) { +func (q *FlowQueryBuilder) addLineFilters(filter filters.Match, values []string) { if len(values) == 0 { return } - if q.config.IsArray(key) { - q.lineFilters = append(q.lineFilters, filters.ArrayLineFilter(key, values, not)) + if q.config.IsArray(filter.Key) { + q.lineFilters = append(q.lineFilters, filters.ArrayLineFilter(filter.Key, values, filter.Not)) } else { var lf filters.LineFilter var hasEmptyMatch bool - if q.config.IsNumeric(key) { - lf, hasEmptyMatch = filters.NumericLineFilter(key, values, not, moreThan) + if q.config.IsNumeric(filter.Key) { + lf, hasEmptyMatch = filters.NumericLineFilter(filter.Key, values, filter.Not, filter.MoreThanOrEqual) + } else if filter.Regex { + lf, hasEmptyMatch = filters.StringLineFilterCheckExact(filter.Key, values, filter.Not) } else { - lf, hasEmptyMatch = filters.StringLineFilterCheckExact(key, values, not) + lf, hasEmptyMatch = filters.StringLineFilter(filter.Key, values, filter.Not) } // if there is at least an empty exact match, there is no uniform/safe way to filter by text, // so we should use JSON label matchers instead of text line matchers diff --git a/pkg/loki/query_test.go b/pkg/loki/query_test.go index b7e66605e..7bf32db96 100644 --- a/pkg/loki/query_test.go +++ b/pkg/loki/query_test.go @@ -12,9 +12,9 @@ import ( func TestFlowQuery_AddLabelFilters(t *testing.T) { cfg := config.Loki{URL: "/", Labels: []string{"foo", "flis"}} query := NewFlowQueryBuilderWithDefaults(&cfg) - err := query.addFilter(filters.NewMatch("foo", `"bar"`)) + err := query.addFilter(filters.NewRegexMatch("foo", `"bar"`)) require.NoError(t, err) - err = query.addFilter(filters.NewMatch("flis", `"flas"`)) + err = query.addFilter(filters.NewRegexMatch("flis", `"flas"`)) require.NoError(t, err) urlQuery := query.Build() assert.Equal(t, `/loki/api/v1/query_range?query={app="netobserv-flowcollector",foo="bar",flis="flas"}`, urlQuery) @@ -23,15 +23,15 @@ func TestFlowQuery_AddLabelFilters(t *testing.T) { func TestQuery_BackQuote_Error(t *testing.T) { cfg := config.Loki{URL: "/", Labels: []string{"lab1", "lab2"}} query := NewFlowQueryBuilderWithDefaults(&cfg) - assert.Error(t, query.addFilter(filters.NewMatch("key", "backquoted`val"))) + assert.Error(t, query.addFilter(filters.NewRegexMatch("key", "backquoted`val"))) } func TestFlowQuery_AddNotLabelFilters(t *testing.T) { cfg := config.Loki{URL: "/", Labels: []string{"foo", "flis"}} query := NewFlowQueryBuilderWithDefaults(&cfg) - err := query.addFilter(filters.NewMatch("foo", `"bar"`)) + err := query.addFilter(filters.NewRegexMatch("foo", `"bar"`)) require.NoError(t, err) - err = query.addFilter(filters.NewNotMatch("flis", `"flas"`)) + err = query.addFilter(filters.NewNotRegexMatch("flis", `"flas"`)) require.NoError(t, err) urlQuery := query.Build() assert.Equal(t, `/loki/api/v1/query_range?query={app="netobserv-flowcollector",foo="bar",flis!="flas"}`, urlQuery) @@ -44,7 +44,7 @@ func backtick(str string) string { func TestFlowQuery_AddLineFilterMultipleValues(t *testing.T) { cfg := config.Loki{URL: "/"} query := NewFlowQueryBuilderWithDefaults(&cfg) - err := query.addFilter(filters.NewMatch("foo", `bar,baz`)) + err := query.addFilter(filters.NewRegexMatch("foo", `bar,baz`)) require.NoError(t, err) urlQuery := query.Build() assert.Equal(t, `/loki/api/v1/query_range?query={app="netobserv-flowcollector"}|~`+backtick(`foo":"(?i)[^"]*bar.*"|foo":"(?i)[^"]*baz.*"`), urlQuery) @@ -53,9 +53,9 @@ func TestFlowQuery_AddLineFilterMultipleValues(t *testing.T) { func TestFlowQuery_AddNotLineFilters(t *testing.T) { cfg := config.Loki{URL: "/"} query := NewFlowQueryBuilderWithDefaults(&cfg) - err := query.addFilter(filters.NewMatch("foo", `"bar"`)) + err := query.addFilter(filters.NewRegexMatch("foo", `"bar"`)) require.NoError(t, err) - err = query.addFilter(filters.NewNotMatch("flis", `"flas"`)) + err = query.addFilter(filters.NewNotRegexMatch("flis", `"flas"`)) require.NoError(t, err) urlQuery := query.Build() assert.Equal(t, `/loki/api/v1/query_range?query={app="netobserv-flowcollector"}|~`+backtick(`foo":"bar"`)+`|~`+backtick(`"flis"`)+`!~`+backtick(`flis":"flas"`), urlQuery) @@ -64,9 +64,9 @@ func TestFlowQuery_AddNotLineFilters(t *testing.T) { func TestFlowQuery_AddLineFiltersWithEmpty(t *testing.T) { cfg := config.Loki{URL: "/"} query := NewFlowQueryBuilderWithDefaults(&cfg) - err := query.addFilter(filters.NewMatch("foo", `"bar"`)) + err := query.addFilter(filters.NewRegexMatch("foo", `"bar"`)) require.NoError(t, err) - err = query.addFilter(filters.NewMatch("flis", `""`)) + err = query.addFilter(filters.NewRegexMatch("flis", `""`)) require.NoError(t, err) urlQuery := query.Build() assert.Equal(t, `/loki/api/v1/query_range?query={app="netobserv-flowcollector"}|~`+backtick(`foo":"bar"`)+`|json|flis=""`, urlQuery) @@ -75,9 +75,9 @@ func TestFlowQuery_AddLineFiltersWithEmpty(t *testing.T) { func TestFlowQuery_AddRecordTypeLabelFilter(t *testing.T) { cfg := config.Loki{URL: "/", Labels: []string{"foo", "flis", "_RecordType"}} query := NewFlowQueryBuilderWithDefaults(&cfg) - err := query.addFilter(filters.NewMatch("foo", `"bar"`)) + err := query.addFilter(filters.NewRegexMatch("foo", `"bar"`)) require.NoError(t, err) - err = query.addFilter(filters.NewMatch("flis", `"flas"`)) + err = query.addFilter(filters.NewRegexMatch("flis", `"flas"`)) require.NoError(t, err) urlQuery := query.Build() assert.Equal(t, `/loki/api/v1/query_range?query={app="netobserv-flowcollector",_RecordType="flowLog",foo="bar",flis="flas"}`, urlQuery) diff --git a/pkg/model/filters/filters.go b/pkg/model/filters/filters.go index 896d2b442..cd21442c3 100644 --- a/pkg/model/filters/filters.go +++ b/pkg/model/filters/filters.go @@ -14,16 +14,25 @@ type SingleQuery = []Match type Match struct { Key string Values string + Regex bool Not bool MoreThanOrEqual bool } -func NewMatch(key, values string) Match { return Match{Key: key, Values: values} } -func NewNotMatch(key, values string) Match { - return Match{Key: key, Values: values, Not: true, MoreThanOrEqual: false} +func NewRegexMatch(key, values string) Match { + return Match{Key: key, Values: values, Regex: true} +} +func NewEqualMatch(key, values string) Match { + return Match{Key: key, Values: values} +} +func NewNotRegexMatch(key, values string) Match { + return Match{Key: key, Values: values, Not: true, Regex: true} +} +func NewNotEqualMatch(key, values string) Match { + return Match{Key: key, Values: values, Not: true} } func NewMoreThanOrEqualMatch(key, values string) Match { - return Match{Key: key, Values: values, Not: false, MoreThanOrEqual: true} + return Match{Key: key, Values: values, MoreThanOrEqual: true} } // Example of raw filters (url-encoded): @@ -45,14 +54,23 @@ func Parse(raw string) (MultiQueries, error) { var andFilters []Match filters := strings.Split(group, "&") for _, filter := range filters { - pair := strings.Split(filter, "=") - if len(pair) == 2 { + if strings.Contains(filter, "=") { + pair := strings.Split(filter, "=") + if len(pair) == 2 { + if strings.HasSuffix(pair[0], "!") { + andFilters = append(andFilters, NewNotEqualMatch(strings.TrimSuffix(pair[0], "!"), pair[1])) + } else if strings.HasSuffix(pair[0], ">") { + andFilters = append(andFilters, NewMoreThanOrEqualMatch(strings.TrimSuffix(pair[0], ">"), pair[1])) + } else { + andFilters = append(andFilters, NewEqualMatch(pair[0], pair[1])) + } + } + } else if strings.Contains(filter, "~") { + pair := strings.Split(filter, "~") if strings.HasSuffix(pair[0], "!") { - andFilters = append(andFilters, NewNotMatch(strings.TrimSuffix(pair[0], "!"), pair[1])) - } else if strings.HasSuffix(pair[0], ">") { - andFilters = append(andFilters, NewMoreThanOrEqualMatch(strings.TrimSuffix(pair[0], ">"), pair[1])) + andFilters = append(andFilters, NewNotRegexMatch(strings.TrimSuffix(pair[0], "!"), pair[1])) } else { - andFilters = append(andFilters, NewMatch(pair[0], pair[1])) + andFilters = append(andFilters, NewRegexMatch(pair[0], pair[1])) } } } @@ -83,7 +101,7 @@ func (m MultiQueries) Distribute(toDistribute []SingleQuery, ignorePred func(Sin func (m *Match) ToLabelFilter() (LabelFilter, bool) { values := strings.Split(m.Values, ",") - if len(values) == 1 && isExactMatch(values[0]) { + if len(values) == 1 && (!m.Regex || isExactMatch(values[0])) { if m.Not { return NotStringLabelFilter(m.Key, trimExactMatch(values[0])), true } else if m.MoreThanOrEqual { @@ -91,7 +109,7 @@ func (m *Match) ToLabelFilter() (LabelFilter, bool) { } return StringEqualLabelFilter(m.Key, trimExactMatch(values[0])), true } - return MultiValuesRegexFilter(m.Key, values, m.Not) + return MultiValuesRegexFilter(m.Key, values, m.Not, !m.Regex) } func isExactMatch(value string) bool { diff --git a/pkg/model/filters/filters_test.go b/pkg/model/filters/filters_test.go index 57e763462..823db76bb 100644 --- a/pkg/model/filters/filters_test.go +++ b/pkg/model/filters/filters_test.go @@ -10,16 +10,16 @@ import ( func TestParseFilters(t *testing.T) { // 2 groups - groups, err := Parse(url.QueryEscape("foo=a,b&bar=c|baz=d")) + groups, err := Parse(url.QueryEscape("foo~a,b&bar=c|baz=d")) require.NoError(t, err) assert.Len(t, groups, 2) assert.Equal(t, SingleQuery{ - NewMatch("foo", "a,b"), - NewMatch("bar", "c"), + NewRegexMatch("foo", "a,b"), + NewEqualMatch("bar", "c"), }, groups[0]) assert.Equal(t, SingleQuery{ - NewMatch("baz", "d"), + NewEqualMatch("baz", "d"), }, groups[1]) // Resource path + port, match all @@ -28,10 +28,10 @@ func TestParseFilters(t *testing.T) { assert.Len(t, groups, 1) assert.Equal(t, SingleQuery{ - NewMatch("SrcK8S_Type", `"Pod"`), - NewMatch("SrcK8S_Namespace", `"default"`), - NewMatch("SrcK8S_Name", `"test"`), - NewMatch("SrcPort", "8080"), + NewEqualMatch("SrcK8S_Type", `"Pod"`), + NewEqualMatch("SrcK8S_Namespace", `"default"`), + NewEqualMatch("SrcK8S_Name", `"test"`), + NewEqualMatch("SrcPort", "8080"), }, groups[0]) // Resource path + port, match any @@ -40,13 +40,13 @@ func TestParseFilters(t *testing.T) { assert.Len(t, groups, 2) assert.Equal(t, SingleQuery{ - NewMatch("SrcK8S_Type", `"Pod"`), - NewMatch("SrcK8S_Namespace", `"default"`), - NewMatch("SrcK8S_Name", `"test"`), + NewEqualMatch("SrcK8S_Type", `"Pod"`), + NewEqualMatch("SrcK8S_Namespace", `"default"`), + NewEqualMatch("SrcK8S_Name", `"test"`), }, groups[0]) assert.Equal(t, SingleQuery{ - NewMatch("SrcPort", "8080"), + NewEqualMatch("SrcPort", "8080"), }, groups[1]) // Resource path + name, match all @@ -55,10 +55,10 @@ func TestParseFilters(t *testing.T) { assert.Len(t, groups, 1) assert.Equal(t, SingleQuery{ - NewMatch("SrcK8S_Type", `"Pod"`), - NewMatch("SrcK8S_Namespace", `"default"`), - NewMatch("SrcK8S_Name", `"test"`), - NewMatch("SrcK8S_Name", `"nomatch"`), + NewEqualMatch("SrcK8S_Type", `"Pod"`), + NewEqualMatch("SrcK8S_Namespace", `"default"`), + NewEqualMatch("SrcK8S_Name", `"test"`), + NewEqualMatch("SrcK8S_Name", `"nomatch"`), }, groups[0]) } @@ -68,45 +68,45 @@ func TestParseCommon(t *testing.T) { assert.Len(t, groups, 2) assert.Equal(t, SingleQuery{ - NewMatch("srcns", "a"), + NewEqualMatch("srcns", "a"), }, groups[0]) assert.Equal(t, SingleQuery{ - NewNotMatch("srcns", "a"), - NewMatch("dstns", "a"), + NewNotEqualMatch("srcns", "a"), + NewEqualMatch("dstns", "a"), }, groups[1]) } func TestDistribute(t *testing.T) { mq := MultiQueries{ - SingleQuery{NewMatch("key1", "a"), NewMatch("key2", "b")}, - SingleQuery{NewMatch("key1", "AA"), NewMatch("key3", "CC")}, - SingleQuery{NewMatch("key-ignore", "ZZ")}, + SingleQuery{NewEqualMatch("key1", "a"), NewEqualMatch("key2", "b")}, + SingleQuery{NewEqualMatch("key1", "AA"), NewEqualMatch("key3", "CC")}, + SingleQuery{NewEqualMatch("key-ignore", "ZZ")}, } - toDistribute := []SingleQuery{{NewMatch("key10", "XX")}, {NewMatch("key11", "YY")}} + toDistribute := []SingleQuery{{NewEqualMatch("key10", "XX")}, {NewEqualMatch("key11", "YY")}} res := mq.Distribute(toDistribute, func(q SingleQuery) bool { return q[0].Key == "key-ignore" }) assert.Len(t, res, 5) assert.Equal(t, SingleQuery{ - NewMatch("key10", "XX"), - NewMatch("key1", "a"), - NewMatch("key2", "b"), + NewEqualMatch("key10", "XX"), + NewEqualMatch("key1", "a"), + NewEqualMatch("key2", "b"), }, res[0]) assert.Equal(t, SingleQuery{ - NewMatch("key11", "YY"), - NewMatch("key1", "a"), - NewMatch("key2", "b"), + NewEqualMatch("key11", "YY"), + NewEqualMatch("key1", "a"), + NewEqualMatch("key2", "b"), }, res[1]) assert.Equal(t, SingleQuery{ - NewMatch("key10", "XX"), - NewMatch("key1", "AA"), - NewMatch("key3", "CC"), + NewEqualMatch("key10", "XX"), + NewEqualMatch("key1", "AA"), + NewEqualMatch("key3", "CC"), }, res[2]) assert.Equal(t, SingleQuery{ - NewMatch("key11", "YY"), - NewMatch("key1", "AA"), - NewMatch("key3", "CC"), + NewEqualMatch("key11", "YY"), + NewEqualMatch("key1", "AA"), + NewEqualMatch("key3", "CC"), }, res[3]) assert.Equal(t, SingleQuery{ - NewMatch("key-ignore", "ZZ"), + NewEqualMatch("key-ignore", "ZZ"), }, res[4]) } diff --git a/pkg/model/filters/logql.go b/pkg/model/filters/logql.go index c5bbe0788..e5e7d7d38 100644 --- a/pkg/model/filters/logql.go +++ b/pkg/model/filters/logql.go @@ -123,26 +123,33 @@ func NotIPLabelFilter(labelKey, cidr string) LabelFilter { } } -func MultiValuesRegexFilter(labelKey string, values []string, not bool) (LabelFilter, bool) { +func MultiValuesRegexFilter(labelKey string, values []string, not bool, equal bool) (LabelFilter, bool) { regexStr := strings.Builder{} for i, value := range values { if i > 0 { regexStr.WriteByte('|') } - // match the begining of string if quoted without a star - // and case insensitive if no quotes - if !strings.HasPrefix(value, `"`) { - regexStr.WriteString("(?i).*") - } else if !strings.HasPrefix(value, `"*`) { - regexStr.WriteString("^") + + if !equal { + // match the begining of string if quoted without a star + // and case insensitive if no quotes + if !strings.HasPrefix(value, `"`) { + regexStr.WriteString("(?i).*") + } else if !strings.HasPrefix(value, `"*`) { + regexStr.WriteString("^") + } } + // inject value with regex regexStr.WriteString(valueReplacer.Replace(value)) - // match the end of string if quoted without a star - if !strings.HasSuffix(value, `"`) { - regexStr.WriteString(".*") - } else if !strings.HasSuffix(value, `*"`) { - regexStr.WriteString("$") + + if !equal { + // match the end of string if quoted without a star + if !strings.HasSuffix(value, `"`) { + regexStr.WriteString(".*") + } else if !strings.HasSuffix(value, `*"`) { + regexStr.WriteString("$") + } } } @@ -272,6 +279,11 @@ func ArrayLineFilter(key string, values []string, not bool) LineFilter { return lf } +// StringLineFilter returns a LineFilter and true if it has an empty match +func StringLineFilter(key string, values []string, not bool) (LineFilter, bool) { + return checkExact(LineFilter{key: key, not: not}, values, typeString) +} + // StringLineFilterCheckExact returns a LineFilter and true if it has an empty match func StringLineFilterCheckExact(key string, values []string, not bool) (LineFilter, bool) { return checkExact(LineFilter{key: key, not: not}, values, typeRegexContains) diff --git a/pkg/prometheus/query.go b/pkg/prometheus/query.go index bb5ad5ddf..ee1ef4502 100644 --- a/pkg/prometheus/query.go +++ b/pkg/prometheus/query.go @@ -35,7 +35,7 @@ func NewQuery(kl map[string][]string, in *loki.TopologyInput, qr *v1.Range, filt func (q *QueryBuilder) Build() Query { labels, extraFilter := GetLabelsAndFilter(q.aggregateKeyLabels, q.in.Aggregate, q.in.Groups) if extraFilter != "" { - q.filters = append(q.filters, filters.NewNotMatch(extraFilter, `""`)) + q.filters = append(q.filters, filters.NewNotRegexMatch(extraFilter, `""`)) } groupBy := strings.Join(labels, ",") diff --git a/pkg/prometheus/query_test.go b/pkg/prometheus/query_test.go index f3c96d336..29bb6fad3 100644 --- a/pkg/prometheus/query_test.go +++ b/pkg/prometheus/query_test.go @@ -105,6 +105,7 @@ func TestBuildQuery_PromQLRateMultiFilter(t *testing.T) { f := filters.SingleQuery{ { Key: fields.SrcNamespace, + Regex: true, Values: `"a","b"`, }, } diff --git a/pkg/server/server_flows_test.go b/pkg/server/server_flows_test.go index c8eb4b31d..bb66b6b28 100644 --- a/pkg/server/server_flows_test.go +++ b/pkg/server/server_flows_test.go @@ -32,55 +32,55 @@ func TestLokiFiltering(t *testing.T) { outputQueries []string outputQueryParts []string }{{ - name: "Simple line filter", + name: "Simple line filter equals", inputPath: "?filters=SrcK8S_Name=test-pod", outputQueries: []string{ - "?query={app=\"netobserv-flowcollector\"}|~`SrcK8S_Name\":\"(?i)[^\"]*test-pod.*\"`", + "?query={app=\"netobserv-flowcollector\"}|~`SrcK8S_Name\":\"test-pod\"`", }, }, { - name: "AND line filter", - inputPath: "?filters=" + url.QueryEscape("Proto=6&SrcK8S_Name=test"), + name: "AND line filter contains", + inputPath: "?filters=" + url.QueryEscape("Proto=6&SrcK8S_Name~test"), outputQueryParts: []string{ "?query={app=\"netobserv-flowcollector\"}", "|~`Proto\":6[,}]`", "|~`SrcK8S_Name\":\"(?i)[^\"]*test.*\"`", }, }, { - name: "OR line filter", + name: "OR line filter equals", inputPath: "?filters=" + url.QueryEscape("Proto=6|SrcK8S_Name=test"), outputQueries: []string{ "?query={app=\"netobserv-flowcollector\"}|~`Proto\":6[,}]`", - "?query={app=\"netobserv-flowcollector\"}|~`SrcK8S_Name\":\"(?i)[^\"]*test.*\"`", + "?query={app=\"netobserv-flowcollector\"}|~`SrcK8S_Name\":\"test\"`", }, }, { - name: "Simple label filter", + name: "Simple label filter equals", inputPath: "?filters=" + url.QueryEscape("SrcK8S_Namespace=test-namespace"), outputQueries: []string{ - `?query={app="netobserv-flowcollector",SrcK8S_Namespace=~"(?i).*test-namespace.*"}`, + `?query={app="netobserv-flowcollector",SrcK8S_Namespace="test-namespace"}`, }, }, { - name: "OR line filter same key", - inputPath: "?filters=" + url.QueryEscape("SrcK8S_Name=name1,name2"), + name: "OR line filter same key contains", + inputPath: "?filters=" + url.QueryEscape("SrcK8S_Name~name1,name2"), outputQueries: []string{ "?query={app=\"netobserv-flowcollector\"}|~`SrcK8S_Name\":\"(?i)[^\"]*name1.*\"|SrcK8S_Name\":\"(?i)[^\"]*name2.*\"`", }, }, { - name: "NOT line filter same key", - inputPath: "?filters=" + url.QueryEscape(`SrcK8S_Name!="name1","name2"`), + name: "NOT line filter same key contains", + inputPath: "?filters=" + url.QueryEscape(`SrcK8S_Name!~"name1","name2"`), outputQueries: []string{ "?query={app=\"netobserv-flowcollector\"}|~`\"SrcK8S_Name\"`!~`SrcK8S_Name\":\"name1\"`!~`SrcK8S_Name\":\"name2\"`", }, }, { - name: "OR label filter same key", + name: "OR label filter same key equals", inputPath: "?filters=" + url.QueryEscape("SrcK8S_Namespace=ns1,ns2"), outputQueries: []string{ - `?query={app="netobserv-flowcollector",SrcK8S_Namespace=~"(?i).*ns1.*|(?i).*ns2.*"}`, + `?query={app="netobserv-flowcollector",SrcK8S_Namespace=~"ns1|ns2"}`, }, }, { name: "Several filters", inputPath: "?filters=" + url.QueryEscape("SrcPort=8080&SrcAddr=10.128.0.1&SrcK8S_Namespace=default"), outputQueries: []string{ - "?query={app=\"netobserv-flowcollector\",SrcK8S_Namespace=~\"(?i).*default.*\"}|~`SrcPort\":8080[,}]`|json|SrcAddr=ip(\"10.128.0.1\")", + "?query={app=\"netobserv-flowcollector\",SrcK8S_Namespace=\"default\"}|~`SrcPort\":8080[,}]`|json|SrcAddr=ip(\"10.128.0.1\")", }, }, { name: "AND IP filters", @@ -106,7 +106,7 @@ func TestLokiFiltering(t *testing.T) { name: "Several OR filters", inputPath: "?filters=" + url.QueryEscape("SrcPort=8080|SrcAddr=10.128.0.1|SrcK8S_Namespace=default"), outputQueries: []string{ - "?query={app=\"netobserv-flowcollector\",SrcK8S_Namespace=~\"(?i).*default.*\"}", + "?query={app=\"netobserv-flowcollector\",SrcK8S_Namespace=\"default\"}", "?query={app=\"netobserv-flowcollector\"}|json|SrcAddr=ip(\"10.128.0.1\")", "?query={app=\"netobserv-flowcollector\"}|~`SrcPort\":8080[,}]`", }, @@ -128,13 +128,13 @@ func TestLokiFiltering(t *testing.T) { outputQueries: []string{`?query={app="netobserv-flowcollector"}&start=${timeNow-300000}`}, }, { name: "Strict label match", - inputPath: "?filters=" + url.QueryEscape("SrcK8S_Namespace=\"exact-namespace\""), + inputPath: "?filters=" + url.QueryEscape("SrcK8S_Namespace~\"exact-namespace\""), outputQueries: []string{ `?query={app="netobserv-flowcollector",SrcK8S_Namespace="exact-namespace"}`, }, }, { name: "Strict line match", - inputPath: "?filters=" + url.QueryEscape("SrcK8S_Name=\"exact-pod\""), + inputPath: "?filters=" + url.QueryEscape("SrcK8S_Name~\"exact-pod\""), outputQueries: []string{ "?query={app=\"netobserv-flowcollector\"}|~`SrcK8S_Name\":\"exact-pod\"`", }, @@ -144,28 +144,28 @@ func TestLokiFiltering(t *testing.T) { outputQueryParts: []string{ "?query={app=\"netobserv-flowcollector\"}", "|~`Port\":8080[,}]`", - "|~`K8S_Name\":\"(?i)[^\"]*test.*\"`", + "|~`K8S_Name\":\"test\"`", }, }, { name: "Common src+dst name with OR", inputPath: "?filters=" + url.QueryEscape("Port=8080|K8S_Name=test"), outputQueries: []string{ - "?query={app=\"netobserv-flowcollector\"}|~`K8S_Name\":\"(?i)[^\"]*test.*\"`", + "?query={app=\"netobserv-flowcollector\"}|~`K8S_Name\":\"test\"`", "?query={app=\"netobserv-flowcollector\"}|~`Port\":8080[,}]`", }, }, { name: "Common src+dst port with AND and OR", inputPath: "?filters=" + url.QueryEscape("Port=8080&SrcK8S_Namespace=test|Port=8080&DstK8S_Namespace=test"), outputQueries: []string{ - "?query={app=\"netobserv-flowcollector\",SrcK8S_Namespace=~\"(?i).*test.*\"}|~`Port\":8080[,}]`", - "?query={app=\"netobserv-flowcollector\",DstK8S_Namespace=~\"(?i).*test.*\"}|~`Port\":8080[,}]`", + "?query={app=\"netobserv-flowcollector\",SrcK8S_Namespace=\"test\"}|~`Port\":8080[,}]`", + "?query={app=\"netobserv-flowcollector\",DstK8S_Namespace=\"test\"}|~`Port\":8080[,}]`", }, }, { name: "Common src+dst port with multiple OR", inputPath: "?filters=" + url.QueryEscape("Port=8080|SrcK8S_Namespace=test|DstK8S_Namespace=test"), outputQueries: []string{ - "?query={app=\"netobserv-flowcollector\",SrcK8S_Namespace=~\"(?i).*test.*\"}", - "?query={app=\"netobserv-flowcollector\",DstK8S_Namespace=~\"(?i).*test.*\"}", + "?query={app=\"netobserv-flowcollector\",SrcK8S_Namespace=\"test\"}", + "?query={app=\"netobserv-flowcollector\",DstK8S_Namespace=\"test\"}", "?query={app=\"netobserv-flowcollector\"}|~`Port\":8080[,}]`", }, }, { @@ -184,7 +184,7 @@ func TestLokiFiltering(t *testing.T) { name: "Empty line filter OR same key", inputPath: "?filters=" + url.QueryEscape(`SrcK8S_Name="",foo&DstK8S_Name="hello"`), outputQueries: []string{ - "?query={app=\"netobserv-flowcollector\"}|~`DstK8S_Name\":\"hello\"`|json|SrcK8S_Name=\"\"+or+SrcK8S_Name=~`(?i).*foo.*`", + "?query={app=\"netobserv-flowcollector\"}|~`DstK8S_Name\":\"hello\"`|json|SrcK8S_Name=\"\"+or+SrcK8S_Name=\"foo\"", }, }, { name: "Empty label ORed", @@ -205,7 +205,7 @@ func TestLokiFiltering(t *testing.T) { inputPath: "?filters=" + url.QueryEscape(`SrcK8S_Name="",foo|DstK8S_Name="hello"`), outputQueries: []string{ "?query={app=\"netobserv-flowcollector\"}|~`DstK8S_Name\":\"hello\"`", - "?query={app=\"netobserv-flowcollector\"}|json|SrcK8S_Name=\"\"+or+SrcK8S_Name=~`(?i).*foo.*`", + "?query={app=\"netobserv-flowcollector\"}|json|SrcK8S_Name=\"\"+or+SrcK8S_Name=\"foo\"", }, }, { name: "Empty line filter ORed (ter)", diff --git a/web/locales/en/plugin__netobserv-plugin.json b/web/locales/en/plugin__netobserv-plugin.json index 6b688be50..392fc512e 100644 --- a/web/locales/en/plugin__netobserv-plugin.json +++ b/web/locales/en/plugin__netobserv-plugin.json @@ -12,7 +12,9 @@ "Cluster name": "Cluster name", "UDN": "UDN", "Can't find metrics for this element. Check your capture filters to ensure we can monitor it. Else it probably means there is no traffic here.": "Can't find metrics for this element. Check your capture filters to ensure we can monitor it. Else it probably means there is no traffic here.", + "Peer A": "Peer A", "Source": "Source", + "Peer B": "Peer B", "Destination": "Destination", "Stats": "Stats", "Top 5 DNS latency": "Top 5 DNS latency", @@ -70,6 +72,9 @@ "Force": "Force", "Grid": "Grid", "Invalid": "Invalid", + "Any": "Any", + "All": "All", + "Peers": "Peers", "rate": "rate", "Average": "Average", "Latest": "Latest", @@ -95,12 +100,9 @@ "Loki": "Loki", "Prometheus": "Prometheus", "Auto": "Auto", - "Match all": "Match all", - "Match any": "Match any", "Fully dropped": "Fully dropped", "Containing drops": "Containing drops", "Without drops": "Without drops", - "All": "All", "Log type to query. A conversation is an aggregation of flows between same peers. Only ended conversations will appear in Overview and Topology tabs.": "Log type to query. A conversation is an aggregation of flows between same peers. Only ended conversations will appear in Overview and Topology tabs.", "Log type": "Log type", "Only available when FlowCollector.processor.logTypes option equals \"CONNECTIONS\", \"ENDED_CONNECTIONS\" or \"ALL\"": "Only available when FlowCollector.processor.logTypes option equals \"CONNECTIONS\", \"ENDED_CONNECTIONS\" or \"ALL\"", @@ -109,8 +111,6 @@ "Datasource": "Datasource", "Only available when FlowCollector.prometheus.enable is true for Overview and Topology tabs": "Only available when FlowCollector.prometheus.enable is true for Overview and Topology tabs", "Only available when FlowCollector.loki.enable is true": "Only available when FlowCollector.loki.enable is true", - "Whether each query result has to match all the filters or just any of them": "Whether each query result has to match all the filters or just any of them", - "Match filters": "Match filters", "Filter flows by their drop status. Only packets dropped by the kernel are monitored here.": "Filter flows by their drop status. Only packets dropped by the kernel are monitored here.", "Fully dropped shows the flows that are 100% dropped": "Fully dropped shows the flows that are 100% dropped", "Containing drops shows the flows having at least one packet dropped": "Containing drops shows the flows having at least one packet dropped", @@ -355,7 +355,6 @@ "Show histogram": "Show histogram", "Hide advanced options": "Hide advanced options", "Show advanced options": "Show advanced options", - "Filter already exists": "Filter already exists", "Hide filters": "Hide filters", "Show {{countActiveFilters}} filters": "Show {{countActiveFilters}} filters", "Show filters": "Show filters", @@ -363,26 +362,46 @@ "Expand": "Expand", "Default filters": "Default filters", "Some filters have been automatically disabled": "Some filters have been automatically disabled", - "Equals": "Equals", - "Not equals": "Not equals", - "More than": "More than", "Learn more": "Learn more", + "Filter already exists": "Filter already exists", + "More than operator is not allowed with `{{searchValue}}`. Use equals or contains operators instead.": "More than operator is not allowed with `{{searchValue}}`. Use equals or contains operators instead.", + "Contains operator is not allowed with `{{searchValue}}`. Use equals or more than operators instead.": "Contains operator is not allowed with `{{searchValue}}`. Use equals or more than operators instead.", + "Can't find filter `{{searchValue}}`": "Can't find filter `{{searchValue}}`", + "Invalid format. The input should be such as `name=netobserv`.": "Invalid format. The input should be such as `name=netobserv`.", + "Filter": "Filter", + "Comparator": "Comparator", + "Add filter": "Add filter", + "Peer": "Peer", + "When a filter has multiple values, the logical OR operator is used between each of these.": "When a filter has multiple values, the logical OR operator is used between each of these.", + "When using match any, the logical OR operator is used between filters.": "When using match any, the logical OR operator is used between filters.", + "When using match {{match}}, the logical AND operator is used between filters.": "When using match {{match}}, the logical AND operator is used between filters.", + "OR": "OR", + "AND": "AND", "Not": "Not", + "equals": "equals", "more than": "more than", + "contains": "contains", "Disable": "Disable", "Enable": "Enable", "group filter": "group filter", "filter": "filter", + "Edit": "Edit", + "As peer A": "As peer A", + "As source": "As source", + "As peer B": "As peer B", + "As destination": "As destination", + "Remove": "Remove", + "Match filters according to your needs.": "Match filters according to your needs.", + "Any will match at least one filter": "Any will match at least one filter", + "All will match all the filters": "All will match all the filters", + "Peers will match all the filters and include the return traffic": "Peers will match all the filters and include the return traffic", + "Match": "Match", "Edit filters": "Edit filters", "Reset defaults": "Reset defaults", "Clear all": "Clear all", "Swap": "Swap", - "Swap source and destination filters": "Swap source and destination filters", - "Back and forth": "Back and forth", - "One way": "One way", - "Switch between one way / back and forth filtering": "Switch between one way / back and forth filtering", - "One way shows traffic strictly as defined per your filters": "One way shows traffic strictly as defined per your filters", - "Back and forth shows traffic according to your filters, plus the related return traffic": "Back and forth shows traffic according to your filters, plus the related return traffic", + "Swap from and to filters": "Swap from and to filters", + "Common": "Common", "Quick filters": "Quick filters", "More options": "More options", "Export overview": "Export overview", @@ -394,6 +413,11 @@ "Columns": "Columns", "Export view": "Export view", "Observe": "Observe", + "Contains": "Contains", + "Not contains": "Not contains", + "Not equals": "Not equals", + "More than": "More than", + "Equals": "Equals", "External": "External", "Last 5 minutes": "Last 5 minutes", "Last 15 minutes": "Last 15 minutes", @@ -418,7 +442,6 @@ "Not a valid MAC address": "Not a valid MAC address", "Unknown protocol": "Unknown protocol", "Unknown direction": "Unknown direction", - "Common": "Common", "(non nodes)": "(non nodes)", "(non pods)": "(non pods)", "internal": "internal", diff --git a/web/src/components/__tests-data__/filters.ts b/web/src/components/__tests-data__/filters.ts index 5a8d0e408..5e374caa5 100644 --- a/web/src/components/__tests-data__/filters.ts +++ b/web/src/components/__tests-data__/filters.ts @@ -1,9 +1,19 @@ /* eslint-disable max-len */ -import { Filter, FilterId, FilterValue } from '../../model/filters'; +import { Filter, FilterCompare, FilterId, FilterValue } from '../../model/filters'; import { findFilter, getFilterDefinitions } from '../../utils/filter-definitions'; import { ColumnConfigSampleDefs } from './columns'; export const FilterConfigSampleDefs = [ + { + id: 'namespace', + name: 'Namespace', + component: 'autocomplete', + autoCompleteAddsQuotes: true, + category: 'targeteable', + hint: 'Specify a single kubernetes name.', + examples: + 'Specify a single kubernetes name following these rules:\n - Containing any alphanumeric, hyphen, underscrore or dot character\n - Partial text like cluster, cluster-image, image-registry\n - Exact match using quotes like "cluster-image-registry"\n - Case sensitive match using quotes like "Deployment"\n - Starting text like cluster, "cluster-*"\n - Ending text like "*-registry"\n - Pattern like "cluster-*-registry", "c*-*-r*y", -i*e-' + }, { id: 'src_namespace', name: 'Namespace', @@ -24,9 +34,18 @@ export const FilterConfigSampleDefs = [ examples: 'Specify a single kubernetes name following these rules:\n - Containing any alphanumeric, hyphen, underscrore or dot character\n - Partial text like cluster, cluster-image, image-registry\n - Exact match using quotes like "cluster-image-registry"\n - Case sensitive match using quotes like "Deployment"\n - Starting text like cluster, "cluster-*"\n - Ending text like "*-registry"\n - Pattern like "cluster-*-registry", "c*-*-r*y", -i*e-' }, + { + id: 'name', + name: 'Name', + component: 'text', + category: 'targeteable', + hint: 'Specify a single kubernetes name.', + examples: + 'Specify a single kubernetes name following these rules:\n - Containing any alphanumeric, hyphen, underscrore or dot character\n - Partial text like cluster, cluster-image, image-registry\n - Exact match using quotes like "cluster-image-registry"\n - Case sensitive match using quotes like "Deployment"\n - Starting text like cluster, "cluster-*"\n - Ending text like "*-registry"\n - Pattern like "cluster-*-registry", "c*-*-r*y", -i*e-' + }, { id: 'src_name', - name: 'name', + name: 'Name', component: 'text', category: 'source', hint: 'Specify a single kubernetes name.', @@ -35,13 +54,20 @@ export const FilterConfigSampleDefs = [ }, { id: 'dst_name', - name: 'name', + name: 'Name', component: 'text', category: 'destination', hint: 'Specify a single kubernetes name.', examples: 'Specify a single kubernetes name following these rules:\n - Containing any alphanumeric, hyphen, underscrore or dot character\n - Partial text like cluster, cluster-image, image-registry\n - Exact match using quotes like "cluster-image-registry"\n - Case sensitive match using quotes like "Deployment"\n - Starting text like cluster, "cluster-*"\n - Ending text like "*-registry"\n - Pattern like "cluster-*-registry", "c*-*-r*y", -i*e-' }, + { + id: 'kind', + name: 'Kind', + component: 'autocomplete', + autoCompleteAddsQuotes: true, + category: 'targeteable' + }, { id: 'src_kind', name: 'Kind', @@ -56,6 +82,15 @@ export const FilterConfigSampleDefs = [ autoCompleteAddsQuotes: true, category: 'destination' }, + { + id: 'owner_name', + name: 'Owner Name', + component: 'text', + category: 'targeteable', + hint: 'Specify a single kubernetes name.', + examples: + 'Specify a single kubernetes name following these rules:\n - Containing any alphanumeric, hyphen, underscrore or dot character\n - Partial text like cluster, cluster-image, image-registry\n - Exact match using quotes like "cluster-image-registry"\n - Case sensitive match using quotes like "Deployment"\n - Starting text like cluster, "cluster-*"\n - Ending text like "*-registry"\n - Pattern like "cluster-*-registry", "c*-*-r*y", -i*e-' + }, { id: 'src_owner_name', name: 'Owner Name', @@ -74,6 +109,16 @@ export const FilterConfigSampleDefs = [ examples: 'Specify a single kubernetes name following these rules:\n - Containing any alphanumeric, hyphen, underscrore or dot character\n - Partial text like cluster, cluster-image, image-registry\n - Exact match using quotes like "cluster-image-registry"\n - Case sensitive match using quotes like "Deployment"\n - Starting text like cluster, "cluster-*"\n - Ending text like "*-registry"\n - Pattern like "cluster-*-registry", "c*-*-r*y", -i*e-' }, + { + id: 'resource', + name: 'Resource', + component: 'autocomplete', + category: 'targeteable', + placeholder: 'E.g: Pod.default.my-pod', + hint: 'Specify an existing resource from its kind, namespace and name.', + examples: + 'Specify a kind, namespace and name from existing:\n - Select kind first from suggestions\n - Then Select namespace from suggestions\n - Finally select name from suggestions\n You can also directly specify a kind, namespace and name like pod.openshift.apiserver' + }, { id: 'src_resource', name: 'Resource', @@ -94,6 +139,15 @@ export const FilterConfigSampleDefs = [ examples: 'Specify a kind, namespace and name from existing:\n - Select kind first from suggestions\n - Then Select namespace from suggestions\n - Finally select name from suggestions\n You can also directly specify a kind, namespace and name like pod.openshift.apiserver' }, + { + id: 'address', + name: 'IP', + component: 'text', + category: 'targeteable', + hint: 'Specify a single IP or range.', + examples: + 'Specify IP following one of these rules:\n - A single IPv4 or IPv6 address like 192.0.2.0, ::1\n - An IP address range like 192.168.0.1-192.189.10.12, 2001:db8::1-2001:db8::8\n - A CIDR specification like 192.51.100.0/24, 2001:db8::/32\n - Empty double quotes "" for an empty IP' + }, { id: 'src_address', name: 'IP', @@ -112,6 +166,16 @@ export const FilterConfigSampleDefs = [ examples: 'Specify IP following one of these rules:\n - A single IPv4 or IPv6 address like 192.0.2.0, ::1\n - An IP address range like 192.168.0.1-192.189.10.12, 2001:db8::1-2001:db8::8\n - A CIDR specification like 192.51.100.0/24, 2001:db8::/32\n - Empty double quotes "" for an empty IP' }, + { + id: 'port', + name: 'Port', + component: 'autocomplete', + category: 'targeteable', + hint: 'Specify a single port number or name.', + examples: + 'Specify a single port following one of these rules:\n - A port number like 80, 21\n - A IANA name like HTTP, FTP', + docUrl: 'https://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml' + }, { id: 'src_port', name: 'Port', @@ -132,6 +196,13 @@ export const FilterConfigSampleDefs = [ 'Specify a single port following one of these rules:\n - A port number like 80, 21\n - A IANA name like HTTP, FTP', docUrl: 'https://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml' }, + { + id: 'mac', + name: 'MAC', + component: 'text', + category: 'targeteable', + hint: 'Specify a single MAC address.' + }, { id: 'src_mac', name: 'MAC', @@ -146,6 +217,15 @@ export const FilterConfigSampleDefs = [ category: 'destination', hint: 'Specify a single MAC address.' }, + { + id: 'host_address', + name: 'Node IP', + component: 'text', + category: 'targeteable', + hint: 'Specify a single IP or range.', + examples: + 'Specify IP following one of these rules:\n - A single IPv4 or IPv6 address like 192.0.2.0, ::1\n - An IP address range like 192.168.0.1-192.189.10.12, 2001:db8::1-2001:db8::8\n - A CIDR specification like 192.51.100.0/24, 2001:db8::/32\n - Empty double quotes "" for an empty IP' + }, { id: 'src_host_address', name: 'Node IP', @@ -164,6 +244,15 @@ export const FilterConfigSampleDefs = [ examples: 'Specify IP following one of these rules:\n - A single IPv4 or IPv6 address like 192.0.2.0, ::1\n - An IP address range like 192.168.0.1-192.189.10.12, 2001:db8::1-2001:db8::8\n - A CIDR specification like 192.51.100.0/24, 2001:db8::/32\n - Empty double quotes "" for an empty IP' }, + { + id: 'host_name', + name: 'Node Name', + component: 'text', + category: 'targeteable', + hint: 'Specify a single kubernetes name.', + examples: + 'Specify a single kubernetes name following these rules:\n - Containing any alphanumeric, hyphen, underscrore or dot character\n - Partial text like cluster, cluster-image, image-registry\n - Exact match using quotes like "cluster-image-registry"\n - Case sensitive match using quotes like "Deployment"\n - Starting text like cluster, "cluster-*"\n - Ending text like "*-registry"\n - Pattern like "cluster-*-registry", "c*-*-r*y", -i*e-' + }, { id: 'src_host_name', name: 'Node Name', @@ -182,6 +271,12 @@ export const FilterConfigSampleDefs = [ examples: 'Specify a single kubernetes name following these rules:\n - Containing any alphanumeric, hyphen, underscrore or dot character\n - Partial text like cluster, cluster-image, image-registry\n - Exact match using quotes like "cluster-image-registry"\n - Case sensitive match using quotes like "Deployment"\n - Starting text like cluster, "cluster-*"\n - Ending text like "*-registry"\n - Pattern like "cluster-*-registry", "c*-*-r*y", -i*e-' }, + { + id: 'zone', + name: 'Zone', + component: 'autocomplete', + category: 'targeteable' + }, { id: 'src_zone', name: 'Zone', @@ -279,6 +374,7 @@ export const FilterDefinitionSample = getFilterDefinitions(FilterConfigSampleDef const filter = (id: FilterId, values: FilterValue[]): Filter => { return { def: findFilter(FilterDefinitionSample, id)!, + compare: FilterCompare.equal, values: values }; }; diff --git a/web/src/components/drawer/element/__tests__/element-panel.spec.tsx b/web/src/components/drawer/element/__tests__/element-panel.spec.tsx index 8892af531..a623c6c99 100644 --- a/web/src/components/drawer/element/__tests__/element-panel.spec.tsx +++ b/web/src/components/drawer/element/__tests__/element-panel.spec.tsx @@ -5,7 +5,7 @@ import { mount, shallow } from 'enzyme'; import * as React from 'react'; import { TopologyMetrics } from '../../../../api/loki'; import { actOn, waitForRender } from '../../../../components/__tests__/common.spec'; -import { Filter } from '../../../../model/filters'; +import { FilterCompare, Filters } from '../../../../model/filters'; import { FlowScope, MetricType } from '../../../../model/flow-query'; import { NodeData } from '../../../../model/topology'; import { createPeer } from '../../../../utils/metrics'; @@ -42,7 +42,7 @@ describe('', () => { droppedMetrics: [], metricType: 'Bytes' as MetricType, metricScope: 'resource' as FlowScope, - filters: [] as Filter[], + filters: { list: [], match: 'all' } as Filters, filterDefinitions: FilterDefinitionSample, setFilters: jest.fn(), onClose: jest.fn(), @@ -143,6 +143,7 @@ describe('', () => { expect(mocks.setFilters).toHaveBeenCalledWith([ { def: expect.any(Object), + compare: FilterCompare.equal, values: [{ v: '10.129.0.15' }] } ]); diff --git a/web/src/components/drawer/element/element-field.tsx b/web/src/components/drawer/element/element-field.tsx index 519c43d40..170e3bce9 100644 --- a/web/src/components/drawer/element/element-field.tsx +++ b/web/src/components/drawer/element/element-field.tsx @@ -1,7 +1,7 @@ import { Flex, FlexItem, Text, TextContent, TextVariants } from '@patternfly/react-core'; import * as React from 'react'; import { TopologyMetricPeer } from '../../../api/loki'; -import { Filter, FilterDefinition } from '../../../model/filters'; +import { Filter, FilterDefinition, Filters } from '../../../model/filters'; import { NodeType } from '../../../model/flow-query'; import { PeerResourceLink } from '../../tabs/netflow-topology/peer-resource-link'; import { SummaryFilterButton } from '../../toolbar/filters/summary-filter-button'; @@ -12,7 +12,7 @@ export interface ElementFieldProps { filterType: NodeType; forcedText?: string; peer: TopologyMetricPeer; - activeFilters: Filter[]; + filters: Filters; setFilters: (filters: Filter[]) => void; filterDefinitions: FilterDefinition[]; } @@ -23,7 +23,7 @@ export const ElementField: React.FC = ({ filterType, forcedText, peer, - activeFilters, + filters, setFilters, filterDefinitions }) => { @@ -37,7 +37,7 @@ export const ElementField: React.FC = ({ void; filterDefinitions: FilterDefinition[]; } @@ -20,7 +20,7 @@ export const ElementFields: React.FC = ({ id, data, forceFirstAsText, - activeFilters, + filters, setFilters, filterDefinitions }) => { @@ -36,7 +36,7 @@ export const ElementFields: React.FC = ({ key={id + '-resource'} label={forceLabel || data.peer.resource.type} forcedText={forceAsText ? data.peer.resource.name : undefined} - activeFilters={activeFilters} + filters={filters} filterType={'resource'} peer={data.peer} setFilters={setFilters} @@ -52,7 +52,7 @@ export const ElementFields: React.FC = ({ key={id + '-owner'} label={forceLabel || data.peer.owner.type} forcedText={forceAsText ? data.peer.owner.name : undefined} - activeFilters={activeFilters} + filters={filters} filterType={'owner'} peer={createPeer({ owner: data.peer.owner, namespace: data.peer.namespace })} setFilters={setFilters} @@ -71,7 +71,7 @@ export const ElementFields: React.FC = ({ key={`${id}-${sc}`} label={forceLabel || sc.name} forcedText={forceAsText ? value : undefined} - activeFilters={activeFilters} + filters={filters} filterType={sc.id} peer={createPeer({ [sc.id]: value })} setFilters={setFilters} @@ -87,7 +87,7 @@ export const ElementFields: React.FC = ({ id={id + '-address'} key={id + '-address'} label={t('IP')} - activeFilters={activeFilters} + filters={filters} filterType={'resource'} peer={createPeer({ addr: data.peer.addr })} setFilters={setFilters} diff --git a/web/src/components/drawer/element/element-panel-content.tsx b/web/src/components/drawer/element/element-panel-content.tsx index 4c53c3f88..c6acffda5 100644 --- a/web/src/components/drawer/element/element-panel-content.tsx +++ b/web/src/components/drawer/element/element-panel-content.tsx @@ -15,14 +15,14 @@ import { FilterIcon, TimesIcon } from '@patternfly/react-icons'; import { BaseEdge, BaseNode } from '@patternfly/react-topology'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import { Filter, FilterDefinition } from '../../../model/filters'; +import { Filter, FilterDefinition, Filters } from '../../../model/filters'; import { GraphElementPeer, isElementFiltered, NodeData, toggleElementFilter } from '../../../model/topology'; import { createPeer } from '../../../utils/metrics'; import { ElementFields } from './element-fields'; export interface ElementPanelContentProps { element: GraphElementPeer; - filters: Filter[]; + filters: Filters; setFilters: (filters: Filter[]) => void; filterDefinitions: FilterDefinition[]; } @@ -53,7 +53,7 @@ export const ElementPanelContent: React.FC = ({ return <>; } const fields = createPeer({ cluster: d.peer.cluster }); - const isFiltered = isElementFiltered(fields, filters, filterDefinitions); + const isFiltered = isElementFiltered(fields, filters.list, filterDefinitions); return ( {t('Cluster name')} @@ -65,7 +65,7 @@ export const ElementPanelContent: React.FC = ({ variant="plain" className="overflow-button" icon={isFiltered ? : } - onClick={() => toggleElementFilter(fields, isFiltered, filters, setFilters, filterDefinitions)} + onClick={() => toggleElementFilter(fields, isFiltered, filters.list, setFilters, filterDefinitions)} /> @@ -81,7 +81,7 @@ export const ElementPanelContent: React.FC = ({ return <>; } const fields = createPeer({ udn: d.peer.udn }); - const isFiltered = isElementFiltered(fields, filters, filterDefinitions); + const isFiltered = isElementFiltered(fields, filters.list, filterDefinitions); return ( {t('UDN')} @@ -93,7 +93,7 @@ export const ElementPanelContent: React.FC = ({ variant="plain" className="overflow-button" icon={isFiltered ? : } - onClick={() => toggleElementFilter(fields, isFiltered, filters, setFilters, filterDefinitions)} + onClick={() => toggleElementFilter(fields, isFiltered, filters.list, setFilters, filterDefinitions)} /> @@ -131,7 +131,7 @@ export const ElementPanelContent: React.FC = ({ id="node-info" data={data} forceFirstAsText={true} - activeFilters={filters} + filters={filters} setFilters={setFilters} filterDefinitions={filterDefinitions} /> @@ -157,7 +157,7 @@ export const ElementPanelContent: React.FC = ({ isExpanded={!hidden.includes('source')} id={'source'} > - {t('Source')} + {filters.match === 'peers' ? t('Peer A') : t('Source')} } = ({ @@ -185,7 +185,7 @@ export const ElementPanelContent: React.FC = ({ isExpanded={!hidden.includes('destination')} id={'destination'} > - {t('Destination')} + {filters.match === 'peers' ? t('Peer B') : t('Destination')} } = ({ diff --git a/web/src/components/drawer/element/element-panel.tsx b/web/src/components/drawer/element/element-panel.tsx index e52dc2bec..3ac35ec80 100644 --- a/web/src/components/drawer/element/element-panel.tsx +++ b/web/src/components/drawer/element/element-panel.tsx @@ -16,7 +16,7 @@ import _ from 'lodash'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { TopologyMetrics } from '../../../api/loki'; -import { Filter, FilterDefinition } from '../../../model/filters'; +import { Filter, FilterDefinition, Filters } from '../../../model/filters'; import { MetricType } from '../../../model/flow-query'; import { GraphElementPeer, NodeData } from '../../../model/topology'; import { defaultSize, maxSize, minSize } from '../../../utils/panel'; @@ -32,7 +32,7 @@ export interface ElementPanelProps { metrics: TopologyMetrics[]; droppedMetrics: TopologyMetrics[]; metricType: MetricType; - filters: Filter[]; + filters: Filters; filterDefinitions: FilterDefinition[]; setFilters: (filters: Filter[]) => void; truncateLength: TruncateLength; diff --git a/web/src/components/drawer/netflow-traffic-drawer.tsx b/web/src/components/drawer/netflow-traffic-drawer.tsx index 3d43d50d8..b89ed006f 100644 --- a/web/src/components/drawer/netflow-traffic-drawer.tsx +++ b/web/src/components/drawer/netflow-traffic-drawer.tsx @@ -3,8 +3,6 @@ import { Drawer, DrawerContent, DrawerContentBody, Flex, FlexItem } from '@patte import { t } from 'i18next'; import _ from 'lodash'; import React from 'react'; -import { GraphElementPeer, TopologyOptions } from 'src/model/topology'; -import { TimeRange } from 'src/utils/datetime'; import { Record } from '../../api/ipfix'; import { getFunctionMetricKey, getRateMetricKey, NetflowMetrics, Stats } from '../../api/loki'; import { Config } from '../../model/config'; @@ -16,10 +14,12 @@ import { hasIndexFields, hasNonIndexFields } from '../../model/filters'; -import { FlowScope, Match, MetricType, RecordType, StatFunction } from '../../model/flow-query'; +import { FlowScope, MetricType, RecordType, StatFunction } from '../../model/flow-query'; import { ScopeConfigDef } from '../../model/scope'; +import { GraphElementPeer, TopologyOptions } from '../../model/topology'; import { Warning } from '../../model/warnings'; import { Column, ColumnSizeMap } from '../../utils/columns'; +import { TimeRange } from '../../utils/datetime'; import { isPromError } from '../../utils/errors'; import { OverviewPanel } from '../../utils/overview-panels'; import { TruncateLength } from '../dropdowns/truncate-dropdown'; @@ -95,7 +95,6 @@ export interface NetflowTrafficDrawerProps { stats?: Stats; lastDuration?: number; warning?: Warning; - match: Match; setShowQuerySummary: (v: boolean) => void; clearSelections: () => void; setSelectedRecord: (v: Record | undefined) => void; @@ -118,7 +117,6 @@ export const NetflowTrafficDrawer: React.FC = React.f topologyMetricFunction, topologyMetricType, setFilters, - match, setShowQuerySummary, clearSelections, setSelectedRecord, @@ -212,12 +210,12 @@ export const NetflowTrafficDrawer: React.FC = React.f (w: Warning | undefined): Warning | undefined => { if (w?.type == 'slow') { let reason = ''; - if (match === 'any' && hasNonIndexFields(filters.list)) { + if (filters.match === 'any' && hasNonIndexFields(filters.list)) { reason = t( // eslint-disable-next-line max-len 'When in "Match any" mode, try using only Namespace, Owner or Resource filters (which use indexed fields), or decrease limit / range, to improve the query performance' ); - } else if (match === 'all' && !hasIndexFields(filters.list)) { + } else if (filters.match === 'all' && !hasIndexFields(filters.list)) { reason = t( // eslint-disable-next-line max-len 'Add Namespace, Owner or Resource filters (which use indexed fields), or decrease limit / range, to improve the query performance' @@ -229,7 +227,7 @@ export const NetflowTrafficDrawer: React.FC = React.f } return w; }, - [match, filters] + [filters] ); const mainContent = () => { @@ -374,7 +372,7 @@ export const NetflowTrafficDrawer: React.FC = React.f droppedMetrics={getTopologyDroppedMetrics() || []} metricType={props.topologyMetricType} truncateLength={props.topologyOptions.truncateLength} - filters={props.filters.list} + filters={props.filters} filterDefinitions={props.filterDefinitions} setFilters={setFiltersList} onClose={() => onElementSelect(undefined)} diff --git a/web/src/components/drawer/record/record-panel.tsx b/web/src/components/drawer/record/record-panel.tsx index d2a01f52a..9291f81e7 100644 --- a/web/src/components/drawer/record/record-panel.tsx +++ b/web/src/components/drawer/record/record-panel.tsx @@ -28,6 +28,7 @@ import { FlowDirection, getDirectionDisplayString, Record } from '../../../api/i import { doesIncludeFilter, Filter, + FilterCompare, FilterDefinition, findFromFilters, removeFromFilters @@ -170,7 +171,10 @@ export const RecordPanel: React.FC = ({ console.error("getDirIntsFilter can't find interfaceCol"); return undefined; } - const interfaceFilterKey = { def: findFilter(filterDefinitions, interfaceCol!.quickFilter!)! }; + const interfaceFilterKey = { + def: findFilter(filterDefinitions, interfaceCol!.quickFilter!)!, + compare: FilterCompare.equal + }; const interfaceFilterValues = _.uniq(record.fields.Interfaces)!.map(v => ({ v, display: v })); const isDeleteInterface = doesIncludeFilter(filters, interfaceFilterKey, interfaceFilterValues); @@ -180,7 +184,10 @@ export const RecordPanel: React.FC = ({ console.error("getDirIntsFilter can't find directionCol"); return undefined; } - const directionFilterKey = { def: findFilter(filterDefinitions, directionCol!.quickFilter!)! }; + const directionFilterKey = { + def: findFilter(filterDefinitions, directionCol!.quickFilter!)!, + compare: FilterCompare.equal + }; const directionFilterValues = _.uniq(record.fields.IfDirections)!.map(v => ({ v: String(v), display: getDirectionDisplayString(String(v) as FlowDirection, t) @@ -202,13 +209,21 @@ export const RecordPanel: React.FC = ({ if (foundInterfaceFilter) { foundInterfaceFilter.values = interfaceFilterValues; } else { - newFilters.push({ def: interfaceFilterKey.def, values: interfaceFilterValues }); + newFilters.push({ + def: interfaceFilterKey.def, + compare: FilterCompare.equal, + values: interfaceFilterValues + }); } const foundDirectionFilter = findFromFilters(newFilters, directionFilterKey); if (foundDirectionFilter) { foundDirectionFilter.values = directionFilterValues; } else { - newFilters.push({ def: directionFilterKey.def, values: directionFilterValues }); + newFilters.push({ + def: directionFilterKey.def, + compare: FilterCompare.equal, + values: directionFilterValues + }); } setFilters(newFilters); } @@ -223,7 +238,7 @@ export const RecordPanel: React.FC = ({ if (!def) { return undefined; } - const filterKey = { def: def }; + const filterKey = { def: def, compare: FilterCompare.equal }; const valueStr = String(value); const isDelete = doesIncludeFilter(filters, filterKey, [{ v: valueStr }]); return { @@ -244,7 +259,7 @@ export const RecordPanel: React.FC = ({ if (found) { found.values = values; } else { - newFilters.push({ def: def, values: values }); + newFilters.push({ def: def, compare: FilterCompare.equal, values: values }); } setFilters(newFilters); } diff --git a/web/src/components/dropdowns/__tests__/query-options-dropdown.spec.tsx b/web/src/components/dropdowns/__tests__/query-options-dropdown.spec.tsx index 619c4f0ff..72d6bb794 100644 --- a/web/src/components/dropdowns/__tests__/query-options-dropdown.spec.tsx +++ b/web/src/components/dropdowns/__tests__/query-options-dropdown.spec.tsx @@ -16,10 +16,8 @@ describe('', () => { allowPktDrops: true, useTopK: false, limit: 100, - match: 'all', packetLoss: 'all', setLimit: jest.fn(), - setMatch: jest.fn(), setPacketLoss: jest.fn(), setRecordType: jest.fn(), setDataSource: jest.fn() @@ -42,10 +40,8 @@ describe('', () => { allowPktDrops: true, useTopK: false, limit: 100, - match: 'all', packetLoss: 'all', setLimit: jest.fn(), - setMatch: jest.fn(), setPacketLoss: jest.fn(), setRecordType: jest.fn(), setDataSource: jest.fn() @@ -53,36 +49,26 @@ describe('', () => { beforeEach(() => { props.setLimit = jest.fn(); - props.setMatch = jest.fn(); }); it('should render component', async () => { const wrapper = shallow(); - expect(wrapper.find('.pf-v5-c-menu__group').length).toBe(5); - expect(wrapper.find('.pf-v5-c-menu__group-title').length).toBe(5); - expect(wrapper.find(Radio)).toHaveLength(15); + expect(wrapper.find('.pf-v5-c-menu__group').length).toBe(4); + expect(wrapper.find('.pf-v5-c-menu__group-title').length).toBe(4); + expect(wrapper.find(Radio)).toHaveLength(13); //setOptions should not be called at startup, because it is supposed to be already initialized from URL expect(props.setLimit).toHaveBeenCalledTimes(0); - expect(props.setMatch).toHaveBeenCalledTimes(0); }); it('should set options', async () => { const wrapper = shallow(); expect(props.setLimit).toHaveBeenCalledTimes(0); - expect(props.setMatch).toHaveBeenCalledTimes(0); act(() => { wrapper.find('#limit-1000').find(Radio).props().onChange!({} as React.FormEvent, true); }); expect(props.setLimit).toHaveBeenNthCalledWith(1, 1000); - expect(props.setMatch).toHaveBeenCalledTimes(0); wrapper.setProps({ ...props, limit: 1000 }); - - act(() => { - wrapper.find('#match-any').find(Radio).props().onChange!({} as React.FormEvent, true); - }); - expect(props.setLimit).toHaveBeenNthCalledWith(1, 1000); - expect(props.setMatch).toHaveBeenNthCalledWith(1, 'any'); }); }); diff --git a/web/src/components/dropdowns/match-dropdown.tsx b/web/src/components/dropdowns/match-dropdown.tsx new file mode 100644 index 000000000..28d28f51d --- /dev/null +++ b/web/src/components/dropdowns/match-dropdown.tsx @@ -0,0 +1,84 @@ +import { Dropdown, DropdownItem, MenuToggle, MenuToggleElement } from '@patternfly/react-core'; +import { BarsIcon, ListIcon, RouteIcon } from '@patternfly/react-icons'; +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Match } from '../../model/flow-query'; + +export interface MatchDropdownProps { + selected: Match; + setMatch: (l: Match) => void; + id?: string; +} + +export const MatchValues = ['all', 'any', 'peers'] as Match[]; + +export const MatchDropdown: React.FC = ({ selected, setMatch, id }) => { + const { t } = useTranslation('plugin__netobserv-plugin'); + const [isOpen, setOpen] = React.useState(false); + + const getMatchDisplay = (layoutName: Match) => { + switch (layoutName) { + case 'any': + return t('Any'); + case 'all': + return t('All'); + case 'peers': + return t('Peers'); + default: + return t('Invalid'); + } + }; + + const getIcon = (layoutName: Match) => { + switch (layoutName) { + case 'any': + return ; + case 'all': + return ; + case 'peers': + return ; + default: + return t('Invalid'); + } + }; + + return ( + ) => ( + setOpen(!isOpen)} + onBlur={() => setTimeout(() => setOpen(false), 500)} + > + {getIcon(selected)} +   + {getMatchDisplay(selected)} + + )} + > + {MatchValues.map(v => ( + { + setOpen(false); + setMatch(v); + }} + > + {getIcon(v)} +   + {getMatchDisplay(v)} + + ))} + + ); +}; + +export default MatchDropdown; diff --git a/web/src/components/dropdowns/query-options-dropdown.tsx b/web/src/components/dropdowns/query-options-dropdown.tsx index f8b2a459e..e06b29e7a 100644 --- a/web/src/components/dropdowns/query-options-dropdown.tsx +++ b/web/src/components/dropdowns/query-options-dropdown.tsx @@ -1,7 +1,7 @@ import { MenuToggle, MenuToggleElement, Select } from '@patternfly/react-core'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import { DataSource, Match, PacketLoss, RecordType } from '../../model/flow-query'; +import { DataSource, PacketLoss, RecordType } from '../../model/flow-query'; import { useOutsideClickEvent } from '../../utils/outside-hook'; import './query-options-dropdown.css'; import { QueryOptionsPanel } from './query-options-panel'; @@ -19,8 +19,6 @@ export interface QueryOptionsProps { useTopK: boolean; limit: number; setLimit: (limit: number) => void; - match: Match; - setMatch: (match: Match) => void; packetLoss: PacketLoss; setPacketLoss: (pl: PacketLoss) => void; } diff --git a/web/src/components/dropdowns/query-options-panel.tsx b/web/src/components/dropdowns/query-options-panel.tsx index e40e5ab46..afd831396 100644 --- a/web/src/components/dropdowns/query-options-panel.tsx +++ b/web/src/components/dropdowns/query-options-panel.tsx @@ -2,7 +2,7 @@ import { Radio, Text, TextContent, TextVariants, Tooltip } from '@patternfly/rea import { InfoAltIcon } from '@patternfly/react-icons'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import { DataSource, Match, PacketLoss, RecordType } from '../../model/flow-query'; +import { DataSource, PacketLoss, RecordType } from '../../model/flow-query'; import { QueryOptionsProps } from './query-options-dropdown'; export const topValues = [5, 10, 15]; @@ -10,8 +10,6 @@ export const limitValues = [50, 100, 500, 1000]; type RecordTypeOption = { label: string; value: RecordType }; type DataSourceOption = { label: string; value: DataSource }; -type MatchOption = { label: string; value: Match }; - type PacketLossOption = { label: string; value: PacketLoss }; // Exported for tests @@ -28,8 +26,6 @@ export const QueryOptionsPanel: React.FC = ({ useTopK, limit, setLimit, - match, - setMatch, packetLoss, setPacketLoss }) => { @@ -61,17 +57,6 @@ export const QueryOptionsPanel: React.FC = ({ } ]; - const matchOptions: MatchOption[] = [ - { - label: t('Match all'), - value: 'all' - }, - { - label: t('Match any'), - value: 'any' - } - ]; - const packetLossOptions: PacketLossOption[] = [ { label: t('Fully dropped'), @@ -198,35 +183,6 @@ export const QueryOptionsPanel: React.FC = ({ ); })} -
- -
- - {t('Match filters')} - -
-
- {matchOptions.map(opt => ( -
- -
- ))} -
= ({ match, obj list: [ { def: findFilter(filterDefinitions, 'src_resource')!, + compare: FilterCompare.equal, values: [{ v: `${obj.kind}.${obj.metadata!.namespace}.${obj.metadata!.name}` }] } ], - backAndForth: true + match: 'peers' }); break; case 'Service': @@ -126,10 +127,11 @@ export const NetflowTrafficTab: React.FC = ({ match, obj list: [ { def: findFilter(filterDefinitions, 'dst_resource')!, + compare: FilterCompare.equal, values: [{ v: `${obj.kind}.${obj.metadata!.namespace}.${obj.metadata!.name}` }] } ], - backAndForth: false + match: 'all' }); break; case 'Route': @@ -138,10 +140,11 @@ export const NetflowTrafficTab: React.FC = ({ match, obj list: [ { def: findFilter(filterDefinitions, 'dst_resource')!, + compare: FilterCompare.equal, values: [{ v: `${route.spec.to!.kind}.${route.metadata!.namespace}.${route.spec.to!.name}` }] } ], - backAndForth: false + match: 'all' }); break; case 'Namespace': @@ -149,10 +152,11 @@ export const NetflowTrafficTab: React.FC = ({ match, obj list: [ { def: findFilter(filterDefinitions, 'src_namespace')!, + compare: FilterCompare.equal, values: [{ v: obj!.metadata!.name as string }] } ], - backAndForth: true + match: 'peers' }); break; case 'Node': @@ -160,10 +164,11 @@ export const NetflowTrafficTab: React.FC = ({ match, obj list: [ { def: findFilter(filterDefinitions, 'src_host_name')!, + compare: FilterCompare.equal, values: [{ v: obj!.metadata!.name as string }] } ], - backAndForth: true + match: 'peers' }); break; case 'ReplicaSet': @@ -171,12 +176,13 @@ export const NetflowTrafficTab: React.FC = ({ match, obj list: [ { def: findFilter(filterDefinitions, 'src_resource')!, + compare: FilterCompare.equal, values: obj.metadata!.ownerReferences!.map(ownerRef => { return { v: `${ownerRef.kind}.${obj.metadata!.namespace}.${ownerRef.name}` }; }) } ], - backAndForth: true + match: 'peers' }); break; case 'HorizontalPodAutoscaler': @@ -185,12 +191,13 @@ export const NetflowTrafficTab: React.FC = ({ match, obj list: [ { def: findFilter(filterDefinitions, 'src_resource')!, + compare: FilterCompare.equal, values: [ { v: `${hpa.spec.scaleTargetRef.kind}.${hpa.metadata!.namespace}.${hpa.spec.scaleTargetRef.name}` } ] } ], - backAndForth: true + match: 'peers' }); break; } diff --git a/web/src/components/netflow-traffic.tsx b/web/src/components/netflow-traffic.tsx index 04aa9def7..c7eb12498 100644 --- a/web/src/components/netflow-traffic.tsx +++ b/web/src/components/netflow-traffic.tsx @@ -34,7 +34,6 @@ import { setURLDatasource, setURLFilters, setURLLimit, - setURLMatch, setURLMetricFunction, setURLMetricType, setURLPacketLoss, @@ -240,9 +239,9 @@ export const NetflowTraffic: React.FC = ({ const resetDefaultFilters = React.useCallback( (c = model.config) => { const def = getDefaultFilters(c); - updateTableFilters({ backAndForth: model.filters.backAndForth, list: def }); + updateTableFilters({ match: model.filters.match, list: def }); }, - [model.config, model.filters.backAndForth, getDefaultFilters, updateTableFilters] + [model.config, model.filters.match, getDefaultFilters, updateTableFilters] ); const setFiltersFromURL = React.useCallback( @@ -265,7 +264,7 @@ export const NetflowTraffic: React.FC = ({ const enabledFilters = getEnabledFilters(forcedFilters || model.filters); const query: FlowQuery = { namespace: forcedNamespace, - filters: filtersToString(enabledFilters.list, model.match === 'any'), + filters: filtersToString(enabledFilters.list, enabledFilters.match === 'any'), limit: limitValues.includes(model.limit) ? model.limit : limitValues[0], recordType: model.recordType, dataSource: model.dataSource, @@ -300,7 +299,6 @@ export const NetflowTraffic: React.FC = ({ forcedNamespace, forcedFilters, model.filters, - model.match, model.limit, model.recordType, model.dataSource, @@ -315,9 +313,9 @@ export const NetflowTraffic: React.FC = ({ const getFetchFunctions = React.useCallback(() => { // check back-and-forth const enabledFilters = getEnabledFilters(forcedFilters || model.filters); - const matchAny = model.match === 'any'; + const matchAny = enabledFilters.match === 'any'; return getBackAndForthFetch(getFilterDefs(), enabledFilters, matchAny); - }, [forcedFilters, model.filters, model.match, getFilterDefs]); + }, [forcedFilters, model.filters, getFilterDefs]); const manageWarnings = React.useCallback( (query: Promise) => { @@ -676,10 +674,6 @@ export const NetflowTraffic: React.FC = ({ setURLLimit(model.limit, !initState.current.includes('configLoaded')); }, [model.limit]); - React.useEffect(() => { - setURLMatch(model.match, !initState.current.includes('configLoaded')); - }, [model.match]); - React.useEffect(() => { setURLShowDup(model.showDuplicates, !initState.current.includes('configLoaded')); }, [model.showDuplicates]); @@ -717,7 +711,6 @@ export const NetflowTraffic: React.FC = ({ model.filters, model.range, model.limit, - model.match, model.showDuplicates, model.topologyMetricFunction, model.topologyMetricType, diff --git a/web/src/components/query-summary/__tests__/summary-panel.spec.tsx b/web/src/components/query-summary/__tests__/summary-panel.spec.tsx index 4c92ce31d..a246eb924 100644 --- a/web/src/components/query-summary/__tests__/summary-panel.spec.tsx +++ b/web/src/components/query-summary/__tests__/summary-panel.spec.tsx @@ -1,7 +1,7 @@ import { Accordion, AccordionItem, DrawerCloseButton } from '@patternfly/react-core'; import { mount, shallow } from 'enzyme'; import * as React from 'react'; -import { NetflowMetrics } from 'src/api/loki'; +import { NetflowMetrics } from '../../../api/loki'; import { FlowsSample } from '../../../components/__tests-data__/flows'; import { waitForRender } from '../../../components/__tests__/common.spec'; import { RecordType } from '../../../model/flow-query'; diff --git a/web/src/components/tabs/netflow-topology/2d/styles/styleDecorators.tsx b/web/src/components/tabs/netflow-topology/2d/styles/styleDecorators.tsx index 65a63ac8b..f476cd036 100644 --- a/web/src/components/tabs/netflow-topology/2d/styles/styleDecorators.tsx +++ b/web/src/components/tabs/netflow-topology/2d/styles/styleDecorators.tsx @@ -10,6 +10,7 @@ import { } from '@patternfly/react-topology'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; +import { Match } from '../../../../../model/flow-query'; import { getDirectionnalScopes, getNonDirectionnalScopes } from '../../../../../model/scope'; import { Decorated, FilterDir, NodeData } from '../../../../../model/topology'; import { ClickableDecorator, ContextMenuDecorator } from './styleDecorator'; @@ -27,6 +28,7 @@ type NodeDecoratorsProps = { data: Decorated; isPinned: boolean; setPinned: (v: boolean) => void; + match: Match; isSrcFiltered: boolean; setSrcFiltered: (v: boolean) => void; isDstFiltered: boolean; @@ -46,6 +48,7 @@ export const NodeDecorators: React.FC = ({ element, isPinned, setPinned, + match, isSrcFiltered, isDstFiltered, setSrcFiltered, @@ -133,7 +136,7 @@ export const NodeDecorators: React.FC = ({ onFilterDirClick('src')} /> @@ -141,7 +144,7 @@ export const NodeDecorators: React.FC = ({ onFilterDirClick('dst')} /> diff --git a/web/src/components/tabs/netflow-topology/2d/styles/styleNode.tsx b/web/src/components/tabs/netflow-topology/2d/styles/styleNode.tsx index 28c44de07..2bd2898b8 100644 --- a/web/src/components/tabs/netflow-topology/2d/styles/styleNode.tsx +++ b/web/src/components/tabs/netflow-topology/2d/styles/styleNode.tsx @@ -27,6 +27,7 @@ import { import useDetailsLevel from '@patternfly/react-topology/dist/esm/hooks/useDetailsLevel'; import * as _ from 'lodash'; import * as React from 'react'; +import { Match } from '../../../../../model/flow-query'; import { Decorated, NodeData } from '../../../../../model/topology'; import DefaultNode from '../components/node'; import { NodeDecorators } from './styleDecorators'; @@ -97,6 +98,7 @@ const StyleNode: React.FC = ({ element, showLabel, dragging, get const data = element.getData() as Decorated | undefined; //TODO: check if we can have intelligent pin on view change const [isPinned, setPinned] = React.useState(data?.isPinned === true); + const [match, setMatch] = React.useState(data?.match || 'all'); const [isSrcFiltered, setSrcFiltered] = React.useState(data?.isSrcFiltered === true); const [isDstFiltered, setDstFiltered] = React.useState(data?.isDstFiltered === true); const detailsLevel = useDetailsLevel(); @@ -107,9 +109,10 @@ const StyleNode: React.FC = ({ element, showLabel, dragging, get }, [data]); React.useEffect(() => { + setMatch(data?.match || 'all'); setSrcFiltered(data?.isSrcFiltered === true); setDstFiltered(data?.isDstFiltered === true); - }, [data?.isSrcFiltered, data?.isDstFiltered]); + }, [data?.isSrcFiltered, data?.isDstFiltered, data?.match]); if (!data || !passedData) { return null; @@ -147,6 +150,7 @@ const StyleNode: React.FC = ({ element, showLabel, dragging, get data={data} isPinned={isPinned} setPinned={setPinned} + match={match} isSrcFiltered={isSrcFiltered} setSrcFiltered={setSrcFiltered} isDstFiltered={isDstFiltered} diff --git a/web/src/components/tabs/netflow-topology/2d/topology-content.tsx b/web/src/components/tabs/netflow-topology/2d/topology-content.tsx index 130b53f44..7cf60c263 100644 --- a/web/src/components/tabs/netflow-topology/2d/topology-content.tsx +++ b/web/src/components/tabs/netflow-topology/2d/topology-content.tsx @@ -223,7 +223,7 @@ export const TopologyContent: React.FC = ({ false, filters.list, list => { - setFilters({ list: list, backAndForth: true }); + setFilters({ list: list, match: 'peers' }); }, filterDefinitions ); diff --git a/web/src/components/tabs/netflow-topology/__tests__/netflow-topology.spec.tsx b/web/src/components/tabs/netflow-topology/__tests__/netflow-topology.spec.tsx index c63a009d2..0d1946e6d 100644 --- a/web/src/components/tabs/netflow-topology/__tests__/netflow-topology.spec.tsx +++ b/web/src/components/tabs/netflow-topology/__tests__/netflow-topology.spec.tsx @@ -6,6 +6,7 @@ import { TopologyMetrics } from '../../../../api/loki'; import { FilterDefinitionSample } from '../../../../components/__tests-data__/filters'; import { ScopeDefSample } from '../../../../components/__tests-data__/scopes'; import { waitForRender } from '../../../../components/__tests__/common.spec'; +import { Filters } from '../../../../model/filters'; import { FlowScope, MetricType, StatFunction } from '../../../../model/flow-query'; import { DefaultOptions, LayoutName } from '../../../../model/topology'; import { defaultTimeRange } from '../../../../utils/router'; @@ -30,7 +31,7 @@ describe('', () => { setOptions: jest.fn(), lowScale: 0.3, medScale: 0.5, - filters: { backAndForth: false, list: [] }, + filters: { match: 'all', list: [] } as Filters, filterDefinitions: FilterDefinitionSample, setFilters: jest.fn(), toggleTopologyOptions: jest.fn(), diff --git a/web/src/components/toolbar/filters-toolbar.css b/web/src/components/toolbar/filters-toolbar.css index 8245d22a5..c7f21e470 100644 --- a/web/src/components/toolbar/filters-toolbar.css +++ b/web/src/components/toolbar/filters-toolbar.css @@ -34,12 +34,6 @@ button.pf-v5-c-button.pf-v5-m-link.pf-v5-m-inline:empty { padding-top: 0; } -/* long-enough to fit help text */ -#filter-toolbar #search, -#filter-toolbar #autocomplete-search { - width: 260px; -} - /* stick "Learn more" text */ #more { padding: 5px 0px 0px 5px; @@ -54,12 +48,14 @@ button.pf-v5-c-button.pf-v5-m-link.pf-v5-m-inline:empty { div#filter-toolbar-search-filters { padding: 0; padding-top: 1em; + padding-bottom: 1em; } .pf-v5-c-toolbar__item.pf-v5-m-align-right { margin-right: 0; } +.custom-chip-peer, .custom-chip-group, .custom-chip { border-radius: 3px; @@ -68,9 +64,20 @@ div#filter-toolbar-search-filters { align-items: center; } +.custom-chip-box { + padding: 0.2em 0.5em 0.2em 0.5em; + margin: 0; + flex-direction: column; +} + +.custom-chip-peer { + color: #000; + background-color: #fafafa; +} + .custom-chip-group { padding: 0.2em 0.5em 0.2em 0.5em; - margin: 0 1em 0 0; + margin: 0; color: #000; background-color: #f0f0f0; } @@ -81,13 +88,19 @@ div#filter-toolbar-search-filters { .custom-chip { min-height: 2em; - padding: 0; - padding-left: 1em; + padding: 0 1em 0 1em; margin-right: 0.5em; color: #000; background-color: #fff; } +.custom-chip::before, +.custom-chip::after { + border: none !important; + border-block-start: none !important; + border-block-end: none !important; +} + .custom-chip-group :nth-child(1 of .custom-chip) { margin-left: 1em; } @@ -111,6 +124,11 @@ div#filter-toolbar-search-filters { background-color: #6A6E73; } +.pf-v5-theme-dark .custom-chip-peer { + color: #fff; + background-color: #002952; +} + .pf-v5-theme-dark .custom-chip-group, .pf-v5-theme-dark .custom-chip-group>button, .pf-v5-theme-dark .custom-chip-group>* { @@ -158,7 +176,7 @@ div#filter-toolbar-search-filters { .custom-chip-group>button, .custom-chip>button { - padding: 0.3em 0.5em 0.3em 0.5em; + padding: 0 0 0 1em; } .custom-chip-group>p, @@ -174,6 +192,8 @@ div#filter-toolbar-search-filters { white-space: nowrap; } +#swap-filters-button, +#reset-filters-button, #clear-all-filters-button { padding: 0; } diff --git a/web/src/components/toolbar/filters-toolbar.tsx b/web/src/components/toolbar/filters-toolbar.tsx index 586c98e43..144291ef4 100644 --- a/web/src/components/toolbar/filters-toolbar.tsx +++ b/web/src/components/toolbar/filters-toolbar.tsx @@ -1,31 +1,19 @@ -import { - Button, - InputGroup, - Toolbar, - ToolbarContent, - ToolbarItem, - Tooltip, - ValidatedOptions -} from '@patternfly/react-core'; +import { Button, Toolbar, ToolbarContent, ToolbarItem, Tooltip, ValidatedOptions } from '@patternfly/react-core'; import { CompressIcon, ExpandIcon } from '@patternfly/react-icons'; import * as _ from 'lodash'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import { Filter, FilterDefinition, Filters, FilterValue, findFromFilters } from '../../model/filters'; +import { Filter, FilterCompare, FilterDefinition, Filters } from '../../model/filters'; import { QuickFilter } from '../../model/quick-filters'; import { autoCompleteCache } from '../../utils/autocomplete-cache'; -import { findFilter } from '../../utils/filter-definitions'; +import { findFilter, matcher } from '../../utils/filter-definitions'; import { Indicator } from '../../utils/filters-helper'; import { localStorageShowFiltersKey, useLocalStorage } from '../../utils/local-storage-hook'; import { QueryOptionsDropdown, QueryOptionsProps } from '../dropdowns/query-options-dropdown'; import './filters-toolbar.css'; -import AutocompleteFilter from './filters/autocomplete-filter'; -import CompareFilter, { FilterCompare } from './filters/compare-filter'; -import { FilterHints } from './filters/filter-hints'; +import { FilterSearchInput } from './filters/filter-search-input'; import { FiltersChips } from './filters/filters-chips'; -import FiltersDropdown from './filters/filters-dropdown'; import { QuickFilters } from './filters/quick-filters'; -import TextFilter from './filters/text-filter'; import { LinksOverflow } from './links-overflow'; export interface FiltersToolbarProps { @@ -43,6 +31,8 @@ export interface FiltersToolbarProps { setFullScreen: (b: boolean) => void; } +export type Direction = 'source' | 'destination' | undefined; + export const FiltersToolbar: React.FC = ({ id, filters, @@ -58,12 +48,19 @@ export const FiltersToolbar: React.FC = ({ ...props }) => { const { t } = useTranslation('plugin__netobserv-plugin'); - const [indicator, setIndicator] = React.useState(ValidatedOptions.default); + const [message, setMessage] = React.useState(); - const [selectedFilter, setSelectedFilter] = React.useState( + const [indicator, setIndicator] = React.useState(ValidatedOptions.default); + + const [searchInputValue, setSearchInputValue] = React.useState(''); + + const [direction, setDirection] = React.useState(); + const [filter, setFilter] = React.useState( findFilter(filterDefinitions, 'src_namespace') || filterDefinitions.length ? filterDefinitions[0] : null ); - const [selectedCompare, setSelectedCompare] = React.useState(FilterCompare.equal); + const [compare, setCompare] = React.useState(FilterCompare.equal); + const [value, setValue] = React.useState(''); + const [showFilters, setShowFilters] = useLocalStorage(localStorageShowFiltersKey, true); // reset and delay message state to trigger tooltip properly @@ -81,72 +78,14 @@ export const FiltersToolbar: React.FC = ({ [skipTipsDelay] ); - const setFiltersList = React.useCallback( - (list: Filter[]) => { - setFilters({ ...filters!, list: list }); - }, - [setFilters, filters] - ); - - const addFilter = React.useCallback( - (filterValue: FilterValue) => { - if (selectedFilter === null) { - console.error('addFilter called with', selectedFilter); - return false; - } - const newFilters = _.cloneDeep(filters?.list) || []; - const not = selectedCompare === FilterCompare.notEqual; - const moreThan = selectedCompare === FilterCompare.moreThanOrEqual; - const found = findFromFilters(newFilters, { def: selectedFilter, not, moreThan }); - if (found) { - if (found.values.map(value => value.v).includes(filterValue.v)) { - setMessageWithDelay(t('Filter already exists')); - setIndicator(ValidatedOptions.error); - return false; - } else { - found.values.push(filterValue); - } - } else { - newFilters.push({ def: selectedFilter, not, moreThan, values: [filterValue] }); - } - setFiltersList(newFilters); - return true; - }, - [filters, selectedCompare, selectedFilter, setFiltersList, setMessageWithDelay, t] - ); - - const getFilterControl = React.useCallback(() => { - if (selectedFilter === null) { - return <>; - } - - const commonProps = { - filterDefinition: selectedFilter, - addFilter: addFilter, - setMessageWithDelay: setMessageWithDelay, - indicator: indicator, - setIndicator: setIndicator - }; - switch (selectedFilter.component) { - case 'text': - case 'number': - return ( - - ); - case 'autocomplete': - return ; - } - }, [selectedFilter, addFilter, setMessageWithDelay, indicator, selectedCompare]); + const editValue = React.useCallback((f: Filter, v: string) => { + setSearchInputValue(matcher(f.def.id, [v], f.compare)); + }, []); const getFilterToolbar = React.useCallback(() => { - if (selectedFilter === null) { + if (!filter) { return <>; } - return ( = ({ enableFlip={false} position={'top'} > -
- - - - {getFilterControl()} - - -
+
); - }, [filterDefinitions, getFilterControl, message, selectedCompare, selectedFilter]); + }, [ + compare, + direction, + filter, + filterDefinitions, + filters, + indicator, + message, + searchInputValue, + setFilters, + setMessageWithDelay, + value + ]); const isForced = !_.isEmpty(forcedFilters); const filtersOrForced = isForced ? forcedFilters : filters; @@ -190,7 +143,6 @@ export const FiltersToolbar: React.FC = ({ } else if (defaultFilters.length > 0) { showHideText = showFilters ? t('Hide filters') : t('Show filters'); } - return ( @@ -199,7 +151,11 @@ export const FiltersToolbar: React.FC = ({ {!isForced && quickFilters.length > 0 && ( - + setFilters({ ...filters!, list })} + /> )} {!isForced && getFilterToolbar()} @@ -243,6 +199,7 @@ export const FiltersToolbar: React.FC = ({ isForced={isForced} filters={filtersOrForced!} setFilters={setFilters} + editValue={editValue} clearFilters={clearFilters} resetFilters={resetFilters} quickFilters={quickFilters} diff --git a/web/src/components/toolbar/filters/__tests__/autocomplete-filter.spec.tsx b/web/src/components/toolbar/filters/__tests__/autocomplete-filter.spec.tsx index b1b463e9b..b6eab7ca0 100644 --- a/web/src/components/toolbar/filters/__tests__/autocomplete-filter.spec.tsx +++ b/web/src/components/toolbar/filters/__tests__/autocomplete-filter.spec.tsx @@ -1,6 +1,7 @@ import { TextInput, ValidatedOptions } from '@patternfly/react-core'; import { mount } from 'enzyme'; import * as React from 'react'; +import { act } from 'react-dom/test-utils'; import { FilterDefinitionSample } from '../../../../components/__tests-data__/filters'; import { actOn } from '../../../../components/__tests__/common.spec'; import { findFilter } from '../../../../utils/filter-definitions'; @@ -10,11 +11,14 @@ describe('', () => { const props: AutocompleteFilterProps = { filterDefinition: findFilter(FilterDefinitionSample, 'src_name')!, indicator: ValidatedOptions.default, + currentValue: '', + setCurrentValue: jest.fn(), addFilter: jest.fn(), - setMessageWithDelay: jest.fn(), + setMessage: jest.fn(), setIndicator: jest.fn() }; beforeEach(() => { + props.setCurrentValue = jest.fn(); props.addFilter = jest.fn(); props.setIndicator = jest.fn(); }); @@ -37,9 +41,16 @@ describe('', () => { // Filter for source name await actOn(() => wrapper.find(TextInput).last().props().onChange!(null!, 'ftp'), wrapper); + expect(props.setCurrentValue).toHaveBeenNthCalledWith(1, 'ftp'); + + // update prop as if the value was stored in parent component + wrapper.setProps({ currentValue: 'ftp' }); await actOn(() => wrapper.find('#autocomplete-search').last().simulate('keydown', { key: 'Enter' }), wrapper); - expect(props.addFilter).toHaveBeenNthCalledWith(1, { v: '21', display: 'ftp' }); + act(() => { + wrapper.update(); + expect(props.addFilter).toHaveBeenNthCalledWith(1, { v: '21', display: 'ftp' }); + }); }); it('should reject invalid port by name', async () => { @@ -60,11 +71,19 @@ describe('', () => { // Filter for source name await actOn(() => textInput.props().onChange!(null!, 'no match'), wrapper); - wrapper.update(); + expect(props.setCurrentValue).toHaveBeenNthCalledWith(1, 'no match'); + + // update prop as if the value was stored in parent component + wrapper.setProps({ currentValue: 'no match' }); expect(props.setIndicator).toHaveBeenNthCalledWith(2, ValidatedOptions.warning); + + // try to filter await actOn(() => wrapper.find('#autocomplete-search').last().simulate('keydown', { key: 'Enter' }), wrapper); - expect(props.setIndicator).toHaveBeenNthCalledWith(3, ValidatedOptions.error); - expect(props.addFilter).toHaveBeenCalledTimes(0); + act(() => { + wrapper.update(); + expect(props.setIndicator).toHaveBeenNthCalledWith(3, ValidatedOptions.error); + expect(props.addFilter).toHaveBeenCalledTimes(0); + }); }); }); diff --git a/web/src/components/toolbar/filters/__tests__/compare-filter.spec.tsx b/web/src/components/toolbar/filters/__tests__/compare-filter.spec.tsx index 173172194..aa9db7a76 100644 --- a/web/src/components/toolbar/filters/__tests__/compare-filter.spec.tsx +++ b/web/src/components/toolbar/filters/__tests__/compare-filter.spec.tsx @@ -1,7 +1,8 @@ import { mount, shallow } from 'enzyme'; import * as React from 'react'; import { actOn, waitForRender } from '../../../../components/__tests__/common.spec'; -import CompareFilter, { CompareFilterProps, FilterCompare } from '../compare-filter'; +import { FilterCompare } from '../../../../model/filters'; +import CompareFilter, { CompareFilterProps } from '../compare-filter'; describe('', () => { const props: CompareFilterProps = { @@ -19,7 +20,6 @@ describe('', () => { const wrapper = mount(); await waitForRender(wrapper); - expect(wrapper.find('#filter-compare-switch-button')).toBeDefined(); expect(wrapper.find('#filter-compare-toggle-button')).toBeDefined(); // No initial call @@ -43,14 +43,8 @@ describe('', () => { await actOn(() => wrapper.find('#filter-compare-toggle-button').last().simulate('click'), wrapper); expect(wrapper.find('[id="more-than"]').length).toBe(0); - //switch directly - await actOn(() => wrapper.find('#filter-compare-switch-button').last().simulate('click'), wrapper); - expect(props.setValue).toHaveBeenCalledWith(FilterCompare.notEqual); - await actOn(() => wrapper.find('#filter-compare-switch-button').last().simulate('click'), wrapper); - expect(props.setValue).toHaveBeenCalledWith(FilterCompare.equal); - - //setState should be called 3 times - expect(props.setValue).toHaveBeenCalledTimes(4); + //setValue should be called 2 times + expect(props.setValue).toHaveBeenCalledTimes(2); }); it('number should have more than', async () => { @@ -64,13 +58,5 @@ describe('', () => { expect(props.setValue).toHaveBeenCalledWith(FilterCompare.moreThanOrEqual); expect(wrapper.find('li').length).toBe(0); - - //switch directly - await actOn(() => wrapper.find('#filter-compare-switch-button').last().simulate('click'), wrapper); - expect(props.setValue).toHaveBeenCalledWith(FilterCompare.notEqual); - await actOn(() => wrapper.find('#filter-compare-switch-button').last().simulate('click'), wrapper); - expect(props.setValue).toHaveBeenCalledWith(FilterCompare.moreThanOrEqual); - await actOn(() => wrapper.find('#filter-compare-switch-button').last().simulate('click'), wrapper); - expect(props.setValue).toHaveBeenCalledWith(FilterCompare.equal); }); }); diff --git a/web/src/components/toolbar/filters/__tests__/filter-search-input.spec.tsx b/web/src/components/toolbar/filters/__tests__/filter-search-input.spec.tsx new file mode 100644 index 000000000..df6a3d8ef --- /dev/null +++ b/web/src/components/toolbar/filters/__tests__/filter-search-input.spec.tsx @@ -0,0 +1,69 @@ +import { SearchInput } from '@patternfly/react-core'; +import { mount } from 'enzyme'; +import * as React from 'react'; +import { FilterCompare } from '../../../../model/filters'; +import { FilterDefinitionSample } from '../../../__tests-data__/filters'; +import { actOn } from '../../../__tests__/common.spec'; +import FilterSearchInput, { FilterSearchInputProps } from '../filter-search-input'; + +describe('', () => { + const props: FilterSearchInputProps = { + filters: { match: 'all', list: [] }, + filterDefinitions: FilterDefinitionSample, + searchInputValue: '', + indicator: undefined, + direction: undefined, + filter: FilterDefinitionSample[0], + compare: FilterCompare.match, + value: '', + setValue: jest.fn(), + setCompare: jest.fn(), + setFilter: jest.fn(), + setDirection: jest.fn(), + setIndicator: jest.fn(), + setSearchInputValue: jest.fn(), + setFilters: jest.fn(), + setMessage: jest.fn() + }; + + it('should parse search input', async () => { + const wrapper = mount(); + const searchInput = wrapper.find(SearchInput).at(0); + expect(searchInput).toBeDefined(); + + // set a complete filter + await actOn(() => searchInput.props().onChange!(null!, 'src_name=loki'), wrapper); + expect(props.setSearchInputValue).toHaveBeenNthCalledWith(1, 'src_name=loki'); + + // update prop as if the value was stored in parent component + wrapper.setProps({ searchInputValue: 'src_name=loki' }); + + const srcNameFilter = FilterDefinitionSample.find(f => f.id === 'src_name'); + expect(props.setDirection).toHaveBeenNthCalledWith(1, 'source'); + expect(props.setFilter).toHaveBeenNthCalledWith(1, srcNameFilter); + expect(props.setCompare).toHaveBeenNthCalledWith(1, '='); + expect(props.setValue).toHaveBeenNthCalledWith(1, 'loki'); + + // update props as if the value was stored in parent component + wrapper.setProps({ direction: 'source', filter: srcNameFilter, compare: '=', value: 'loki' }); + + //open popper + await actOn(() => wrapper.find('[aria-label="Open advanced search"]').last().simulate('click'), wrapper); + + //check form content + const popper = wrapper.find('#filter-popper'); + expect(popper.find('#direction-filter-toggle').last().text()).toBe('Source'); + expect(popper.find('#column-filter-toggle').last().text()).toBe('Name'); + expect(popper.find('#filter-compare-toggle-button').last().text()).toBe('Equals='); + expect(popper.find('#search').last().props().value).toBe('loki'); + + // add filter + await actOn(() => wrapper.find('#add-form-filter').last().simulate('click'), wrapper); + expect(props.setFilters).toHaveBeenNthCalledWith(1, { + list: [{ compare: '=', def: srcNameFilter, values: [{ v: 'loki' }] }], + match: 'all' + }); + expect(props.setSearchInputValue).toHaveBeenCalledWith(''); + expect(props.setValue).toHaveBeenCalledWith(''); + }); +}); diff --git a/web/src/components/toolbar/filters/__tests__/filters-chips.spec.tsx b/web/src/components/toolbar/filters/__tests__/filters-chips.spec.tsx index 10730560b..8ae646b94 100644 --- a/web/src/components/toolbar/filters/__tests__/filters-chips.spec.tsx +++ b/web/src/components/toolbar/filters/__tests__/filters-chips.spec.tsx @@ -5,8 +5,9 @@ import { FiltersChips, FiltersChipsProps } from '../filters-chips'; describe('', () => { const props: FiltersChipsProps = { - filters: { backAndForth: false, list: [] }, + filters: { match: 'all', list: [] }, setFilters: jest.fn(), + editValue: jest.fn(), clearFilters: jest.fn(), resetFilters: jest.fn(), quickFilters: [], diff --git a/web/src/components/toolbar/filters/__tests__/filters-toolbar.spec.tsx b/web/src/components/toolbar/filters/__tests__/filters-toolbar.spec.tsx index d71a0aa4d..74af65113 100644 --- a/web/src/components/toolbar/filters/__tests__/filters-toolbar.spec.tsx +++ b/web/src/components/toolbar/filters/__tests__/filters-toolbar.spec.tsx @@ -1,4 +1,4 @@ -import { Accordion, AccordionItem, Button, Dropdown, Toolbar, ToolbarItem } from '@patternfly/react-core'; +import { Button, Dropdown, DropdownItem, Toolbar, ToolbarItem } from '@patternfly/react-core'; import { mount, shallow } from 'enzyme'; import * as React from 'react'; import { FilterDefinitionSample } from '../../../../components/__tests-data__/filters'; @@ -7,7 +7,7 @@ import FiltersToolbar, { FiltersToolbarProps } from '../../../toolbar/filters-to describe('', () => { const props: FiltersToolbarProps = { - filters: { backAndForth: false, list: [] }, + filters: { match: 'all', list: [] }, filterDefinitions: FilterDefinitionSample, forcedFilters: undefined, skipTipsDelay: true, @@ -25,10 +25,8 @@ describe('', () => { allowLoki: true, allowPktDrops: true, useTopK: false, - match: 'all', packetLoss: 'all', setLimit: jest.fn(), - setMatch: jest.fn(), setPacketLoss: jest.fn(), setRecordType: jest.fn(), setDataSource: jest.fn() @@ -54,18 +52,24 @@ describe('', () => { it('should open and close', async () => { const wrapper = mount(); + expect(wrapper.find('#filter-popper').length).toBe(0); expect(wrapper.find('.column-filter-item').length).toBe(0); + //open popper + await actOn(() => wrapper.find('[aria-label="Open advanced search"]').last().simulate('click'), wrapper); + //open dropdow await actOn(() => wrapper.find('#column-filter-toggle').at(0).simulate('click'), wrapper); expect(wrapper.find('.column-filter-item').length).toBeGreaterThan(0); - expect(wrapper.find(Accordion).length).toBe(1); - expect(wrapper.find(AccordionItem).length).toBeGreaterThan(0); + expect(wrapper.find(DropdownItem).length).toBeGreaterThan(0); //close dropdow await actOn(() => wrapper.find('#column-filter-toggle').at(0).simulate('click'), wrapper); expect(wrapper.find('.column-filter-item').length).toBe(0); + //close popper + await actOn(() => wrapper.find('[aria-label="Open advanced search"]').last().simulate('click'), wrapper); + //setFilters should not be called at startup, because filters are supposed to be already initialized from URL expect(props.setFilters).toHaveBeenCalledTimes(0); }); @@ -73,27 +77,30 @@ describe('', () => { it('should show tips on complex fields', async () => { const wrapper = mount(); + //open popper + await actOn(() => wrapper.find('[aria-label="Open advanced search"]').last().simulate('click'), wrapper); + //open dropdow await actOn(() => wrapper.find('#column-filter-toggle').at(0).simulate('click'), wrapper); - //select Src workload - await actOn(() => wrapper.find('[id="src_name"]').last().simulate('click'), wrapper); + //select name + await actOn(() => wrapper.find('[id="name"]').last().simulate('click'), wrapper); let tips = wrapper.find('#tips').at(0).getElement(); expect(String(tips.props.children[0].props.children)).toContain('Specify a single kubernetes name'); //open dropdow await actOn(() => wrapper.find('#column-filter-toggle').at(0).simulate('click'), wrapper); - //select Src port - await actOn(() => wrapper.find('[id="src_port"]').last().simulate('click'), wrapper); + //select port + await actOn(() => wrapper.find('[id="port"]').last().simulate('click'), wrapper); tips = wrapper.find('#tips').at(0).getElement(); expect(String(tips.props.children[0].props.children)).toContain('Specify a single port'); //open dropdow await actOn(() => wrapper.find('#column-filter-toggle').at(0).simulate('click'), wrapper); - //select Src address - await actOn(() => wrapper.find('[id="src_address"]').last().simulate('click'), wrapper); + //select address + await actOn(() => wrapper.find('[id="address"]').last().simulate('click'), wrapper); tips = wrapper.find('#tips').at(0).getElement(); expect(String(tips.props.children[0].props.children)).toContain('Specify a single IP'); diff --git a/web/src/components/toolbar/filters/__tests__/text-filter.spec.tsx b/web/src/components/toolbar/filters/__tests__/text-filter.spec.tsx index 34b487535..711a5f48a 100644 --- a/web/src/components/toolbar/filters/__tests__/text-filter.spec.tsx +++ b/web/src/components/toolbar/filters/__tests__/text-filter.spec.tsx @@ -1,8 +1,9 @@ import { TextInput, ValidatedOptions } from '@patternfly/react-core'; +import { act } from '@testing-library/react'; import { mount } from 'enzyme'; import * as React from 'react'; -import { act } from 'react-dom/test-utils'; import { FilterDefinitionSample } from '../../../../components/__tests-data__/filters'; +import { actOn } from '../../../../components/__tests__/common.spec'; import { findFilter } from '../../../../utils/filter-definitions'; import TextFilter, { TextFilterProps } from '../text-filter'; @@ -10,21 +11,22 @@ describe('', () => { const props: TextFilterProps = { filterDefinition: findFilter(FilterDefinitionSample, 'src_name')!, indicator: ValidatedOptions.default, + currentValue: '', + setCurrentValue: jest.fn(), addFilter: jest.fn(), - setMessageWithDelay: jest.fn(), + setMessage: jest.fn(), setIndicator: jest.fn() }; beforeEach(() => { + props.setCurrentValue = jest.fn(); props.addFilter = jest.fn(); props.setIndicator = jest.fn(); }); - it('should filter name', done => { + it('should filter name', async () => { const wrapper = mount(); - const textInput = wrapper.find(TextInput).at(0); - const searchButton = wrapper.find('#search-button').at(0); + const textInput = wrapper.find(TextInput).last(); expect(textInput).toBeDefined(); - expect(searchButton).toBeDefined(); // No initial call expect(props.addFilter).toHaveBeenCalledTimes(0); @@ -33,34 +35,22 @@ describe('', () => { expect(textInput.props().validated).toBe(ValidatedOptions.default); // Filter for source name - act(() => { - textInput.props().onChange!(null!, 'abcd'); - }); - setImmediate(() => { - wrapper.update(); - expect(props.setIndicator).toHaveBeenNthCalledWith(2, ValidatedOptions.success); - expect(props.addFilter).toHaveBeenCalledTimes(0); + await actOn(() => wrapper.find(TextInput).last().props().onChange!(null!, 'abcd'), wrapper); + expect(props.setCurrentValue).toHaveBeenNthCalledWith(1, 'abcd'); - // Add filter - searchButton.simulate('click'); - - setImmediate(() => { - wrapper.update(); - expect(props.addFilter).toHaveBeenNthCalledWith(1, { v: 'abcd' }); - done(); - }); - }); + // update prop as if the value was stored in parent component + wrapper.setProps({ currentValue: 'abcd' }); + await actOn(() => wrapper.find('#search').last().simulate('keydown', { key: 'Enter' }), wrapper); + expect(props.addFilter).toHaveBeenNthCalledWith(1, { v: 'abcd' }); }); - it('should filter valid IP', done => { + it('should filter valid IP', async () => { const wrapper = mount( ); const textInput = wrapper.find(TextInput).at(0); - const searchButton = wrapper.find('#search-button').at(0); expect(textInput).toBeDefined(); - expect(searchButton).toBeDefined(); // No initial call expect(props.addFilter).toHaveBeenCalledTimes(0); @@ -69,29 +59,27 @@ describe('', () => { expect(textInput.props().validated).toBe(ValidatedOptions.default); // Filter for dest IP - act(() => { - textInput.props().onChange!(null!, '10.0.0.1'); - }); + await actOn(() => wrapper.find(TextInput).last().props().onChange!(null!, '10.0.0.1'), wrapper); + expect(props.setCurrentValue).toHaveBeenNthCalledWith(1, '10.0.0.1'); - // Add filter - searchButton.simulate('click'); + // update prop as if the value was stored in parent component + wrapper.setProps({ currentValue: '10.0.0.1' }); - setImmediate(() => { + // Add filter + await actOn(() => wrapper.find('#search').last().simulate('keydown', { key: 'Enter' }), wrapper); + act(() => { wrapper.update(); expect(props.addFilter).toHaveBeenNthCalledWith(1, { v: '10.0.0.1' }); - done(); }); }); - it('should not filter invalid IP', done => { + it('should not filter invalid IP', async () => { const wrapper = mount( ); const textInput = wrapper.find(TextInput).at(0); - const searchButton = wrapper.find('#search-button').at(0); expect(textInput).toBeDefined(); - expect(searchButton).toBeDefined(); // No initial call expect(props.addFilter).toHaveBeenCalledTimes(0); @@ -100,23 +88,21 @@ describe('', () => { expect(textInput.props().validated).toBe(ValidatedOptions.default); // Filter for dest IP + await actOn(() => wrapper.find(TextInput).last().props().onChange!(null!, '10.0.'), wrapper); + expect(props.setCurrentValue).toHaveBeenNthCalledWith(1, '10.0.'); + + // update prop as if the value was stored in parent component + wrapper.setProps({ currentValue: '10.0.' }); + expect(props.setIndicator).toHaveBeenNthCalledWith(2, ValidatedOptions.warning); + expect(props.addFilter).toHaveBeenCalledTimes(0); + + // try to filter + await actOn(() => wrapper.find('#search').last().simulate('keydown', { key: 'Enter' }), wrapper); + act(() => { - textInput.props().onChange!(null!, '10.0.'); - }); - setImmediate(() => { wrapper.update(); expect(props.setIndicator).toHaveBeenNthCalledWith(2, ValidatedOptions.warning); expect(props.addFilter).toHaveBeenCalledTimes(0); - - // Add filter - searchButton.simulate('click'); - - setImmediate(() => { - wrapper.update(); - expect(props.setIndicator).toHaveBeenNthCalledWith(2, ValidatedOptions.warning); - expect(props.addFilter).toHaveBeenCalledTimes(0); - done(); - }); }); }); }); diff --git a/web/src/components/toolbar/filters/autocomplete-filter.css b/web/src/components/toolbar/filters/autocomplete-filter.css index e885b52fa..af8d339d1 100644 --- a/web/src/components/toolbar/filters/autocomplete-filter.css +++ b/web/src/components/toolbar/filters/autocomplete-filter.css @@ -1,4 +1,11 @@ #autocomplete-menu-button { - width: 30px; + width: 2.2em; + height: 2.2em; padding: 0; +} + +#autocomplete-container { + margin: 0; + padding: 0; + min-width: 330px; } \ No newline at end of file diff --git a/web/src/components/toolbar/filters/autocomplete-filter.tsx b/web/src/components/toolbar/filters/autocomplete-filter.tsx index c0bc74b61..682ed88de 100644 --- a/web/src/components/toolbar/filters/autocomplete-filter.tsx +++ b/web/src/components/toolbar/filters/autocomplete-filter.tsx @@ -1,5 +1,7 @@ import { Button, + Flex, + FlexItem, Menu, MenuContent, MenuItem, @@ -8,15 +10,13 @@ import { TextInput, ValidatedOptions } from '@patternfly/react-core'; -import { CaretDownIcon, SearchIcon } from '@patternfly/react-icons'; +import { CaretDownIcon } from '@patternfly/react-icons'; import * as _ from 'lodash'; import * as React from 'react'; import { createFilterValue, FilterDefinition, FilterOption, FilterValue } from '../../../model/filters'; -import { autoCompleteCache } from '../../../utils/autocomplete-cache'; import { getHTTPErrorDetails } from '../../../utils/errors'; import { undefinedValue } from '../../../utils/filter-definitions'; import { Indicator } from '../../../utils/filters-helper'; -import { usePrevious } from '../../../utils/previous-hook'; import './autocomplete-filter.css'; const optionsMenuID = 'options-menu-list'; @@ -27,33 +27,29 @@ const isMenuOption = (elt?: Element) => { export interface AutocompleteFilterProps { filterDefinition: FilterDefinition; addFilter: (filter: FilterValue) => boolean; - setMessageWithDelay: (m: string | undefined) => void; + setMessage: (m: string | undefined) => void; indicator: Indicator; setIndicator: (ind: Indicator) => void; + currentValue: string; + setCurrentValue: (v: string) => void; } export const AutocompleteFilter: React.FC = ({ filterDefinition, addFilter: addFilterParent, - setMessageWithDelay, + setMessage, indicator, - setIndicator + setIndicator, + currentValue, + setCurrentValue }) => { const autocompleteContainerRef = React.useRef(null); const searchInputRef = React.useRef(null); const optionsRef = React.useRef(null); const [options, setOptions] = React.useState([]); - const [currentValue, setCurrentValue] = React.useState(''); - const previousFilterDefinition = usePrevious(filterDefinition); React.useEffect(() => { - if (filterDefinition !== previousFilterDefinition) { - //reset filter value if definition has changed - resetFilterValue(); - searchInputRef.current?.focus(); - searchInputRef.current?.setAttribute('autocomplete', 'off'); - autoCompleteCache.clear(); - } else if (_.isEmpty(currentValue)) { + if (_.isEmpty(currentValue)) { setIndicator(ValidatedOptions.default); } else { //update validation icon on field on value change @@ -66,9 +62,9 @@ export const AutocompleteFilter: React.FC = ({ const resetFilterValue = React.useCallback(() => { setCurrentValue(''); setOptions([]); - setMessageWithDelay(undefined); + setMessage(undefined); setIndicator(ValidatedOptions.default); - }, [setCurrentValue, setMessageWithDelay, setIndicator, setOptions]); + }, [setCurrentValue, setMessage, setIndicator, setOptions]); const addFilter = React.useCallback( (option: FilterOption) => { @@ -87,14 +83,16 @@ export const AutocompleteFilter: React.FC = ({ setCurrentValue(newValue); filterDefinition .getOptions(newValue) - .then(setOptions) + .then(v => { + setOptions(v); + }) .catch(err => { const errorMessage = getHTTPErrorDetails(err); - setMessageWithDelay(errorMessage); + setMessage(errorMessage); setOptions([]); }); }, - [setOptions, setCurrentValue, filterDefinition, setMessageWithDelay] + [setOptions, setCurrentValue, filterDefinition, setMessage] ); const onAutoCompleteOptionSelected = React.useCallback( @@ -142,7 +140,7 @@ export const AutocompleteFilter: React.FC = ({ const validation = filterDefinition.validate(v); //show tooltip and icon when user try to validate filter if (!_.isEmpty(validation.err)) { - setMessageWithDelay(validation.err); + setMessage(validation.err); setIndicator(ValidatedOptions.error); return; } @@ -157,7 +155,7 @@ export const AutocompleteFilter: React.FC = ({ filterDefinition, currentValue, onAutoCompleteOptionSelected, - setMessageWithDelay, + setMessage, setIndicator, addFilterParent, resetFilterValue @@ -175,8 +173,13 @@ export const AutocompleteFilter: React.FC = ({ }, []); return ( - <> -
+ +
= ({ } isVisible={!_.isEmpty(options)} enableFlip={false} - appendTo={autocompleteContainerRef.current!} + appendTo={autocompleteContainerRef.current || undefined} />
- - - + + + +
); }; diff --git a/web/src/components/toolbar/filters/compare-filter.tsx b/web/src/components/toolbar/filters/compare-filter.tsx index 05db0f2c8..11d255270 100644 --- a/web/src/components/toolbar/filters/compare-filter.tsx +++ b/web/src/components/toolbar/filters/compare-filter.tsx @@ -1,14 +1,9 @@ -import { Dropdown, DropdownItem, MenuToggle, MenuToggleAction, MenuToggleElement } from '@patternfly/react-core'; +import { Badge, Dropdown, DropdownItem, MenuToggle, MenuToggleElement } from '@patternfly/react-core'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import { FilterComponent } from '../../../model/filters'; +import { FilterCompare, FilterComponent, getCompareText } from '../../../model/filters'; import { usePrevious } from '../../../utils/previous-hook'; -export enum FilterCompare { - equal = 1, - notEqual, - moreThanOrEqual -} export interface CompareFilterProps { value: FilterCompare; setValue: (newState: FilterCompare) => void; @@ -20,58 +15,74 @@ export const CompareFilter: React.FC = ({ value, setValue, c const [isOpen, setOpen] = React.useState(false); const prevComponent = usePrevious(component); - const dropdownItems = [ - onSelect(FilterCompare.equal)}> - {t('Equals')} - , - onSelect(FilterCompare.notEqual)}> - {t('Not equals')} - - ]; + const getText = React.useCallback((v: FilterCompare) => getCompareText(v, t), [t]); + + const onSelect = React.useCallback( + (v: FilterCompare) => { + setValue(v); + setOpen(false); + }, + [setValue] + ); - if (component === 'number') { - dropdownItems.push( + const getItems = React.useCallback(() => { + const dropdownItems = [ + onSelect(FilterCompare.equal)} + > + {FilterCompare.equal} + , onSelect(FilterCompare.moreThanOrEqual)} + description={getText(FilterCompare.notEqual)} + onClick={() => onSelect(FilterCompare.notEqual)} > - {t('More than')} + {FilterCompare.notEqual} - ); - } + ]; - const onSelect = (v: FilterCompare) => { - setValue(v); - setOpen(false); - }; - - const onSwitch = React.useCallback(() => { - const filterCompareValues = [FilterCompare.equal, FilterCompare.notEqual]; if (component === 'number') { - filterCompareValues.push(FilterCompare.moreThanOrEqual); - } - - const nextIndex = filterCompareValues.indexOf(value) + 1; - if (nextIndex < filterCompareValues.length) { - setValue(filterCompareValues[nextIndex]); + dropdownItems.push( + onSelect(FilterCompare.moreThanOrEqual)} + > + {FilterCompare.moreThanOrEqual} + + ); } else { - setValue(filterCompareValues[0]); + dropdownItems.push( + onSelect(FilterCompare.match)} + > + {FilterCompare.match} + , + onSelect(FilterCompare.notMatch)} + > + {FilterCompare.notMatch} + + ); } - }, [component, value, setValue]); - - const getSymbol = React.useCallback(() => { - switch (value) { - case FilterCompare.notEqual: - return '!='; - case FilterCompare.moreThanOrEqual: - return '>='; - case FilterCompare.equal: - default: - return '='; - } - }, [value]); + return dropdownItems; + }, [component, getText, onSelect]); React.useEffect(() => { // reset to equal when component change @@ -81,31 +92,24 @@ export const CompareFilter: React.FC = ({ value, setValue, c }, [component, prevComponent, setValue]); return ( - <> - ) => ( - - {getSymbol()} - - ] - }} - onClick={() => setOpen(!isOpen)} - isExpanded={isOpen} - onBlur={() => setTimeout(() => setOpen(false), 500)} - /> - )} - > - {dropdownItems} - - + ) => ( + {value}} + onClick={() => setOpen(!isOpen)} + isExpanded={isOpen} + onBlur={() => setTimeout(() => setOpen(false), 500)} + > + {getText(value)} + + )} + > + {getItems()} + ); }; diff --git a/web/src/components/toolbar/filters/filter-hints.css b/web/src/components/toolbar/filters/filter-hints.css new file mode 100644 index 000000000..93878ea3c --- /dev/null +++ b/web/src/components/toolbar/filters/filter-hints.css @@ -0,0 +1,3 @@ +#tips { + max-width: 360px; +} \ No newline at end of file diff --git a/web/src/components/toolbar/filters/filter-hints.tsx b/web/src/components/toolbar/filters/filter-hints.tsx index e946ee38f..2ce1baf33 100644 --- a/web/src/components/toolbar/filters/filter-hints.tsx +++ b/web/src/components/toolbar/filters/filter-hints.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { FilterDefinition } from '../../../model/filters'; +import './filter-hints.css'; export interface FilterHintsProps { def: FilterDefinition; diff --git a/web/src/components/toolbar/filters/filter-search-input.css b/web/src/components/toolbar/filters/filter-search-input.css new file mode 100644 index 000000000..2fa682b33 --- /dev/null +++ b/web/src/components/toolbar/filters/filter-search-input.css @@ -0,0 +1,7 @@ +#filter-search-input, #filter-popper { + min-width: 400px !important; +} + +.filters-actions { + margin-left: 180px; +} \ No newline at end of file diff --git a/web/src/components/toolbar/filters/filter-search-input.tsx b/web/src/components/toolbar/filters/filter-search-input.tsx new file mode 100644 index 000000000..ac2c36614 --- /dev/null +++ b/web/src/components/toolbar/filters/filter-search-input.tsx @@ -0,0 +1,610 @@ +import { + ActionGroup, + Button, + Form, + FormGroup, + Menu, + MenuContent, + MenuItem, + MenuList, + Panel, + PanelMain, + PanelMainBody, + Popper, + SearchInput, + ValidatedOptions +} from '@patternfly/react-core'; +import _ from 'lodash'; +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + FilterCompare, + FilterDefinition, + FilterOption, + Filters, + FilterValue, + findFromFilters, + getCompareText +} from '../../../model/filters'; +import { getHTTPErrorDetails } from '../../../utils/errors'; +import { matcher } from '../../../utils/filter-definitions'; +import { Indicator, setTargeteableFilters } from '../../../utils/filters-helper'; +import { useOutsideClickEvent } from '../../../utils/outside-hook'; +import { usePrevious } from '../../../utils/previous-hook'; +import { Direction } from '../filters-toolbar'; +import AutocompleteFilter from './autocomplete-filter'; +import CompareFilter from './compare-filter'; +import { FilterHints } from './filter-hints'; +import './filter-search-input.css'; +import FiltersDropdown from './filters-dropdown'; +import TextFilter from './text-filter'; + +interface FormUpdateResult { + def?: FilterDefinition; + comparator?: FilterCompare; + value?: string; + hasError: boolean; +} + +interface Suggestion { + display?: string; + value: string; + validate?: boolean; +} + +export interface FilterSearchInputProps { + filterDefinitions: FilterDefinition[]; + filters?: Filters; + searchInputValue: string; + indicator: Indicator; + direction: Direction; + filter: FilterDefinition; + compare: FilterCompare; + value: string; + setValue: (v: string) => void; + setCompare: (v: FilterCompare) => void; + setFilter: (v: FilterDefinition) => void; + setDirection: (v: Direction) => void; + setIndicator: (v: Indicator) => void; + setSearchInputValue: (v: string) => void; + setFilters: (v: Filters) => void; + setMessage: (m: string | undefined) => void; +} + +export const FilterSearchInput: React.FC = ({ + filterDefinitions, + filters, + searchInputValue, + indicator, + direction, + filter, + compare, + value, + setValue, + setCompare, + setFilter, + setDirection, + setIndicator, + setSearchInputValue, + setFilters, + setMessage +}) => { + const { t } = useTranslation('plugin__netobserv-plugin'); + + const filterSearchInputContainerRef = React.useRef(null); + const searchInputRef = React.useRef(null); + const popperRef = useOutsideClickEvent(() => { + // delay this to avoid conflict with onToggle event + // clicking on the arrow will skip the onToggle and trigger this code after the delay + setTimeout(() => { + if (suggestions.length) { + setSuggestions([]); + } else { + setPopperOpen(false); + setSearchInputValue(getEncodedValue()); + // clear search field to show the placeholder back + if (_.isEmpty(value)) { + setSearchInputValue(''); + } + } + }, 100); + }); + const [suggestions, setSuggestions] = React.useState([]); + const prevSuggestions = usePrevious(suggestions); + const [isPopperOpen, setPopperOpen] = React.useState(false); + const [submitPending, setSubmitPending] = React.useState(false); + + const reset = React.useCallback(() => { + setCompare(FilterCompare.equal); + setValue(''); + setSearchInputValue(''); + }, [setCompare, setSearchInputValue, setValue]); + + const addFilter = React.useCallback( + (filterValue: FilterValue) => { + let newFilters = _.cloneDeep(filters?.list) || []; + const def = filter; + const found = findFromFilters(newFilters, { def, compare }); + if (found) { + if (found.values.map(value => value.v).includes(filterValue.v)) { + setMessage(t('Filter already exists')); + setIndicator(ValidatedOptions.error); + return false; + } else { + found.values.push(filterValue); + } + } else { + newFilters.push({ def, compare, values: [filterValue] }); + } + + // force peers mode to have directions set + if (filters?.match === 'peers') { + newFilters = setTargeteableFilters(filterDefinitions, newFilters, direction === 'destination' ? 'dst' : 'src'); + } + setFilters({ ...filters!, list: newFilters }); + setPopperOpen(false); + setSuggestions([]); + reset(); + return true; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [filter, filters, filterDefinitions, compare, setFilters, setMessage] + ); + + const addFilterFromSuggestions = React.useCallback( + (sug: Suggestion[] | undefined = prevSuggestions) => { + if (!value.length) { + addFilter({ display: t('n/a'), v: `""` }); + } + // check if a previous suggestion match value, else just add it as filter + const found = filter.component === 'autocomplete' && sug?.find(s => s.value === value || s.display === value); + if (found) { + addFilter({ display: found.display, v: found.value }); + } else { + addFilter({ v: value }); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [addFilter, filter.component, prevSuggestions, value] + ); + + const updateForm = React.useCallback( + (v: string = searchInputValue, submitOnRefresh?: boolean) => { + // parse search input value to form content + const fieldValue = v.split(/>=|!=|!~|=|~/); + const result: FormUpdateResult = { hasError: false }; + + // if field + value are valid, we should end with 2 items only + if (fieldValue.length == 2) { + const searchValue = fieldValue[0].toLowerCase(); + const def = filterDefinitions.find(def => def.id.toLowerCase() === searchValue); + if (def) { + // set compare + if (v.includes(FilterCompare.moreThanOrEqual)) { + if (def.component != 'number') { + setMessage( + t( + 'More than operator is not allowed with `{{searchValue}}`. Use equals or contains operators instead.', + { searchValue } + ) + ); + setIndicator(ValidatedOptions.error); + return { ...result, hasError: true }; + } + setCompare(FilterCompare.moreThanOrEqual); + result.comparator = FilterCompare.moreThanOrEqual; + } else if (v.includes(FilterCompare.notEqual)) { + setCompare(FilterCompare.notEqual); + result.comparator = FilterCompare.notEqual; + } else if (v.includes(FilterCompare.equal)) { + setCompare(FilterCompare.equal); + result.comparator = FilterCompare.equal; + } else { + if (def.component === 'number') { + setMessage( + t( + 'Contains operator is not allowed with `{{searchValue}}`. Use equals or more than operators instead.', + { searchValue } + ) + ); + setIndicator(ValidatedOptions.error); + return { ...result, hasError: true }; + } else if (v.includes(FilterCompare.notMatch)) { + setCompare(FilterCompare.notMatch); + result.comparator = FilterCompare.notMatch; + } else { + setCompare(FilterCompare.match); + result.comparator = FilterCompare.match; + } + } + // set direction + if (def.category === 'source') { + setDirection('source'); + } else if (def.category === 'destination') { + setDirection('destination'); + } else { + setDirection(undefined); + } + //set filter + setFilter(def); + result.def = def; + } else if (submitOnRefresh) { + setMessage(t("Can't find filter `{{searchValue}}`", { searchValue })); + setIndicator(ValidatedOptions.error); + return { ...result, hasError: true }; + } + setValue(fieldValue[1]); + result.value = fieldValue[1]; + } else if (fieldValue.length === 1) { + // check if the value match a field + const searchValue = v.toLowerCase(); + const def = filterDefinitions.find(def => def.id.toLowerCase() === searchValue); + if (def) { + // set direction + if (def.category === 'source') { + setDirection('source'); + } else if (def.category === 'destination') { + setDirection('destination'); + } else { + setDirection(undefined); + } + // set filter and reset the rest + setFilter(def); + setCompare(FilterCompare.match); + setValue(''); + result.def = def; + } else { + // set simple value on current filter + setValue(v); + result.value = v; + } + } else { + setMessage(t('Invalid format. The input should be such as `name=netobserv`.')); + setIndicator(ValidatedOptions.error); + return { ...result, hasError: true }; + } + + if (submitOnRefresh) { + setSubmitPending(true); + } + + return result; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [compare, filterDefinitions, searchInputValue, setMessage] + ); + + const getEncodedValue = React.useCallback( + (v: string = value) => { + return matcher(filter.id, [v], compare); + }, + [compare, filter, value] + ); + + const onToggle = React.useCallback(() => { + setSuggestions([]); + if (!isPopperOpen) { + updateForm(); + setPopperOpen(true); + } + }, [isPopperOpen, updateForm]); + + const onSearchChange = React.useCallback( + (v: string) => { + const defToSuggestion = (fd: FilterDefinition) => { + return { + display: + fd.category === 'source' + ? `${t('Source')} ${fd.name}` + : fd.category === 'destination' + ? `${t('Destination')} ${fd.name}` + : fd.name, + value: fd.id, + validate: false + }; + }; + + const optionToSuggestion = (o: FilterOption) => { + return { display: o.name !== o.value ? o.name : undefined, value: o.value, validate: true }; + }; + + setSearchInputValue(v); + const updated = updateForm(v); + if (!v.length || updated.hasError) { + setSuggestions([]); + return; + } else if (updated.def) { + if (updated.comparator) { + // suggest values if autocomplete and field set + if (filter.component === 'autocomplete') { + filter + .getOptions(updated.value || '') + .then(v => { + setSuggestions(v.map(optionToSuggestion)); + }) + .catch(err => { + const errorMessage = getHTTPErrorDetails(err); + setMessage(errorMessage); + setSuggestions([]); + }); + } else { + // cleanup suggestions if values can't be guessed + setSuggestions([]); + } + } else { + // suggest comparators if field set but not value + let suggestions = Object.values(FilterCompare).map(fc => { + return { display: getCompareText(fc, t), value: fc, validate: false }; + }) as Suggestion[]; + if (filter.component === 'number') { + suggestions = suggestions.filter( + s => s.value !== FilterCompare.match && s.value !== FilterCompare.notMatch + ); + } else { + suggestions = suggestions.filter(s => s.value != FilterCompare.moreThanOrEqual); + } + // also suggest other definitions starting by the same id + setSuggestions( + suggestions.concat( + filterDefinitions + .filter(fd => fd.id !== updated.def!.id && fd.id.startsWith(updated.def!.id)) + .map(defToSuggestion) + ) + ); + } + } else if (updated.value?.length) { + // suggest fields if def is not matched yet + const suggestions = filterDefinitions + .filter(fd => fd.id.startsWith(updated.value!)) + .map(defToSuggestion) as Suggestion[]; + if (filter.component === 'autocomplete') { + filter + .getOptions(updated.value) + .then(v => { + setSuggestions(suggestions.concat(v.map(optionToSuggestion))); + }) + .catch(err => { + const errorMessage = getHTTPErrorDetails(err); + setMessage(errorMessage); + setSuggestions(suggestions); + }); + } else { + setSuggestions(suggestions); + } + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [filter, filterDefinitions, setMessage, setSearchInputValue, updateForm] + ); + + const searchInput = React.useCallback( + () => ( + { + if (suggestions.length) { + // focus on suggestions on tab / arrow down keys + if (e.key === 'Tab' || e.key === 'ArrowDown') { + e.preventDefault(); + document.getElementById('suggestion-0')?.focus(); + } else if (e.key === 'Escape') { + // clear suggestions on esc key + setSuggestions([]); + } + } else if (e.key === 'ArrowDown') { + // get suggestions back + onSearchChange(searchInputValue); + } + }} + onChange={(e, v) => onSearchChange(v)} + onSearch={(e, v) => { + setSuggestions([]); + if (_.isEmpty(v)) { + setPopperOpen(true); + } else { + setSearchInputValue(v); + updateForm(v, true); + } + }} + onToggleAdvancedSearch={onToggle} + value={isPopperOpen ? getEncodedValue() : searchInputValue} + isAdvancedSearchOpen={isPopperOpen} + placeholder={filter?.hint} + ref={searchInputRef} + id="filter-search-input" + /> + ), + [ + filter?.hint, + getEncodedValue, + isPopperOpen, + onSearchChange, + onToggle, + reset, + searchInputValue, + setSearchInputValue, + suggestions.length, + updateForm + ] + ); + + const popper = React.useCallback(() => { + return ( + + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + popperRef, + suggestions, + filterDefinitions, + direction, + setDirection, + filter, + setFilter, + compare, + setCompare, + addFilter, + setMessage, + indicator, + setIndicator, + value, + setValue, + reset, + updateForm, + searchInputValue, + onSearchChange + ]); + + React.useEffect(() => { + if (submitPending) { + setSubmitPending(false); + addFilterFromSuggestions(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [submitPending, setSubmitPending, addFilter, value]); + + return ( +
+ 0} + enableFlip={false} + appendTo={filterSearchInputContainerRef.current || undefined} + /> +
+ ); +}; + +export default FilterSearchInput; diff --git a/web/src/components/toolbar/filters/filters-chips.css b/web/src/components/toolbar/filters/filters-chips.css index dc37ef90a..81395ba53 100644 --- a/web/src/components/toolbar/filters/filters-chips.css +++ b/web/src/components/toolbar/filters/filters-chips.css @@ -1,6 +1,40 @@ .toolbar-group { display: flex !important; flex-direction: row !important; - align-items: center !important; + align-items: flex-end !important; gap: 1em !important; +} + +.custom-chip.pf-v5-c-menu-toggle { + padding: 0 !important; +} + +.custom-chip>.pf-v5-c-menu-toggle__text { + padding: 0.25em 1em 0.25em 1em; +} + +.custom-chip>.pf-v5-c-menu-toggle__controls { + padding: 0.25em; +} + +.match-container { + align-self: center; +} + +.match-text { + text-align: center; +} + +.and-or-text { + align-self: center; + padding: 0 0.5em 0 0.5em; +} + +.flex-block { + display: flex; + flex-wrap: wrap; + align-items: center; + align-content: center; + align-self: center; + justify-content: center; } \ No newline at end of file diff --git a/web/src/components/toolbar/filters/filters-chips.tsx b/web/src/components/toolbar/filters/filters-chips.tsx index b0a994cfc..143559906 100644 --- a/web/src/components/toolbar/filters/filters-chips.tsx +++ b/web/src/components/toolbar/filters/filters-chips.tsx @@ -1,20 +1,56 @@ -import { Button, Text, TextContent, TextVariants, ToolbarGroup, ToolbarItem, Tooltip } from '@patternfly/react-core'; -import { LongArrowAltDownIcon, LongArrowAltUpIcon, TimesCircleIcon, TimesIcon } from '@patternfly/react-icons'; +import { + Button, + Dropdown, + DropdownItem, + DropdownList, + Flex, + FlexItem, + MenuToggle, + MenuToggleElement, + Text, + TextContent, + TextVariants, + ToolbarGroup, + ToolbarItem, + Tooltip +} from '@patternfly/react-core'; +import { + ArrowLeftIcon, + ArrowRightIcon, + ArrowsAltVIcon, + BanIcon, + CheckIcon, + InfoAltIcon, + PencilAltIcon, + TimesCircleIcon, + TimesIcon +} from '@patternfly/react-icons'; import * as _ from 'lodash'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { Filter, + FilterCompare, FilterDefinition, Filters, filtersEqual, + FilterValue, hasEnabledFilterValues, removeFromFilters } from '../../../model/filters'; +import { Match } from '../../../model/flow-query'; import { QuickFilter } from '../../../model/quick-filters'; import { autoCompleteCache } from '../../../utils/autocomplete-cache'; -import { getFilterFullName, hasSrcDstFilters, swapFilters } from '../../../utils/filters-helper'; +import { + bnfFilterValue, + hasSrcAndDstFilters, + hasSrcOrDstFilters, + setTargeteableFilters, + swapFilters, + swapFilterValue +} from '../../../utils/filters-helper'; import { getPathWithParams, netflowTrafficPath } from '../../../utils/url'; +import { MatchDropdown } from '../../dropdowns/match-dropdown'; import { navigate } from '../../dynamic-loader/dynamic-loader'; import { LinksOverflow } from '../links-overflow'; import './filters-chips.css'; @@ -23,16 +59,23 @@ export interface FiltersChipsProps { isForced: boolean; filters: Filters; setFilters: (v: Filters) => void; + editValue: (f: Filter, v: string) => void; clearFilters: () => void; resetFilters: () => void; quickFilters: QuickFilter[]; filterDefinitions: FilterDefinition[]; } +export interface FiltersGroup { + id: 'src' | 'dst' | 'common'; + filters: Filter[]; +} + export const FiltersChips: React.FC = ({ isForced, filters, setFilters, + editValue, clearFilters, resetFilters, quickFilters, @@ -40,6 +83,45 @@ export const FiltersChips: React.FC = ({ }) => { const { t } = useTranslation('plugin__netobserv-plugin'); + const [openedDropdown, setOpenedDropdown] = React.useState(); + + const getGroupName = React.useCallback( + (id: 'src' | 'dst' | 'common') => { + if (id === 'common') { + return ''; + } + if (filters.match === 'peers') { + if (hasSrcAndDstFilters(filters.list)) { + return id === 'src' ? t('Peer A') : t('Peer B'); + } + return t('Peer'); + } + return id === 'src' ? t('Source') : t('Destination'); + }, + [filters.list, filters.match, t] + ); + + const getGroups = React.useCallback(() => { + const srcGroup: FiltersGroup = { id: 'src', filters: [] }; + const dstGroup: FiltersGroup = { id: 'dst', filters: [] }; + const commonGroup: FiltersGroup = { id: 'common', filters: [] }; + filters.list.forEach(f => { + if (f.def.id.startsWith('src_')) { + srcGroup.filters.push(f); + } else if (f.def.id.startsWith('dst_')) { + dstGroup.filters.push(f); + } else { + commonGroup.filters.push(f); + } + }); + return [srcGroup, dstGroup, commonGroup]; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filters.list, filters.match]); + + const getDefaultFilters = React.useCallback(() => { + return quickFilters.filter(qf => qf.default).flatMap(qf => qf.filters); + }, [quickFilters]); + const setFiltersList = React.useCallback( (list: Filter[]) => { setFilters({ ...filters, list: list }); @@ -47,101 +129,287 @@ export const FiltersChips: React.FC = ({ [setFilters, filters] ); - const defaultFilters = quickFilters.filter(qf => qf.default).flatMap(qf => qf.filters); - - const swapSrcDst = React.useCallback(() => { + const swapAllSrcDst = React.useCallback(() => { const swapped = swapFilters(filterDefinitions, filters!.list); setFilters({ ...filters!, list: swapped }); }, [filterDefinitions, filters, setFilters]); - const toggleBackAndForth = React.useCallback(() => { - setFilters({ ...filters!, backAndForth: !filters!.backAndForth }); - }, [setFilters, filters]); + const swapValue = React.useCallback( + (filter: Filter, value: FilterValue, target: 'src' | 'dst') => { + const list = swapFilterValue(filterDefinitions, filters!.list, filter.def.id, value, target); + setFilters({ ...filters!, list }); + setOpenedDropdown(undefined); + }, + [filterDefinitions, filters, setFilters] + ); - const chipFilters = filters.list; - if (_.isEmpty(chipFilters) && _.isEmpty(defaultFilters)) { - return null; - } - const isDefaultFilters = filtersEqual(chipFilters, defaultFilters); - const isSrcDst = hasSrcDstFilters(chipFilters!); + const removeValue = React.useCallback( + (filter: Filter, value: FilterValue) => { + filter.values = filter.values.filter(val => val.v !== value.v); + if (_.isEmpty(filter.values)) { + setFiltersList(removeFromFilters(filters.list, filter)); + } else { + setFilters(_.cloneDeep(filters)); + } + setOpenedDropdown(undefined); + }, + [filters, setFilters, setFiltersList] + ); - return ( - - - {chipFilters && - chipFilters.map((chipFilter, cfIndex) => { - let fullName = getFilterFullName(chipFilter.def, t); - if (chipFilter.not) { - fullName = t('Not') + ' ' + fullName; - } - if (chipFilter.moreThan) { - fullName = fullName + ' ' + t('more than'); - } - const someEnabled = hasEnabledFilterValues(chipFilter); - return ( -
- - { - //switch all values if no remaining - chipFilter.values.forEach(fv => { - fv.disabled = someEnabled; - }); - setFilters(_.cloneDeep(filters)); - }} - > - {fullName} - - - {chipFilter.values.map((chipFilterValue, fvIndex) => { - return ( -
+ const getAndOrText = React.useCallback( + (match: Match | 'values', index: number) => { + if (index == 0) { + return undefined; + } + + return ( + + + {match === 'any' || match === 'values' ? t('OR') : t('AND')} + + + ); + }, + [t] + ); + + const getFullName = React.useCallback( + (filter: Filter) => { + switch (filter.compare) { + case FilterCompare.notEqual: + return `${t('Not')} ${filter.def.name} ${t('equals')}`; + case FilterCompare.equal: + return `${filter.def.name} ${t('equals')}`; + case FilterCompare.moreThanOrEqual: + return `${filter.def.name} ${t('more than')}`; + case FilterCompare.notMatch: + return `${t('Not')} ${filter.def.name} ${t('contains')}`; + case FilterCompare.match: + return `${filter.def.name} ${t('contains')}`; + } + }, + [t] + ); + + const getFilterDisplay = React.useCallback( + (filter: Filter, cfIndex: number) => { + const someEnabled = hasEnabledFilterValues(filter); + return ( +
+ {getAndOrText(filters.match, cfIndex)} +
+ + { + //switch all values if no remaining + filter.values.forEach(fv => { + fv.disabled = someEnabled; + }); + setFilters(_.cloneDeep(filters)); + }} + > + {getFullName(filter)} + + + {filter.values.map((filterValue, fvIndex) => { + if (isForced || filterValue.disabled) { + return ( +
+ {getAndOrText('values', fvIndex)} +
{ - //switch value - chipFilterValue.disabled = !chipFilterValue.disabled; + filterValue.disabled = !filterValue.disabled; setFilters(_.cloneDeep(filters)); }} > - {chipFilterValue.display ? chipFilterValue.display : chipFilterValue.v} + {filterValue.display ? filterValue.display : filterValue.v} - {!isForced && ( - - )}
- ); - })} - {!isForced && ( - - )} +
+ ); + } + + const dropdownId = `${filter.def.id}-${fvIndex}`; + return ( +
+ {getAndOrText('values', fvIndex)} + setOpenedDropdown(isOpen ? dropdownId : undefined)} + toggle={(toggleRef: React.Ref) => ( + setOpenedDropdown(openedDropdown === dropdownId ? undefined : dropdownId)} + > + {filterValue.display ? filterValue.display : filterValue.v} + + )} + > + + { + removeValue(filter, filterValue); + editValue(filter, filterValue.v); + }} + > + +  {t('Edit')} + + {filters.match !== 'peers' && + (filter.def.id.startsWith('src_') || filter.def.id.startsWith('dst_')) && ( + { + const bnf = bnfFilterValue(filterDefinitions, filters!.list, filter.def.id, filterValue); + setFilters({ ...filters!, list: bnf }); + setOpenedDropdown(undefined); + }} + > + +  {t('Any')} + + )} + {(filter.def.category === 'targeteable' || filter.def.id.startsWith('dst_')) && ( + swapValue(filter, filterValue, 'src')}> + +  {filters.match === 'peers' ? t('As peer A') : t('As source')} + + )} + {(filter.def.category === 'targeteable' || filter.def.id.startsWith('src_')) && ( + swapValue(filter, filterValue, 'dst')}> + +  {filters.match === 'peers' ? t('As peer B') : t('As destination')} + + )} + { + filterValue.disabled = !filterValue.disabled; + setFilters(_.cloneDeep(filters)); + setOpenedDropdown(undefined); + }} + > + {filterValue.disabled && } + {!filterValue.disabled && } +  {filterValue.disabled ? t('Enable') : t('Disable')} + + { + removeValue(filter, filterValue); + }} + > + +  {t('Remove')} + + + +
+ ); + })} + {!isForced && ( + + )} +
+
+ ); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [filterDefinitions, filters, isForced, openedDropdown, setFilters, setFiltersList, swapValue] + ); + + const setMatch = React.useCallback( + (v: Match) => { + const existingFilters = filters; + // convert all targeteable filters to a single peer + if (v !== 'any') { + existingFilters.list = setTargeteableFilters(filterDefinitions, existingFilters.list, 'src'); + } + setFilters({ ...existingFilters, match: v }); + }, + [filters, setFilters, filterDefinitions] + ); + + if (_.isEmpty(filters.list) && _.isEmpty(getDefaultFilters())) { + return null; + } + const isDefaultFilters = filtersEqual(filters.list, getDefaultFilters()); + + return ( + + {!isForced && (filters.list.length >= 2 || hasSrcOrDstFilters(filters.list)) && ( + + + + + {t('Match filters according to your needs.')} + + - {t('Any will match at least one filter')} + + + - {t('All will match all the filters')} + + + - {t('Peers will match all the filters and include the return traffic')} + + + } + > + + {t('Match')} + + + + + + + + + )} + + {getGroups() + .filter(gp => gp.filters.length) + .map((gp, index) => { + return ( +
+ {getAndOrText(filters.match, index)} +
+ {hasSrcOrDstFilters(filters.list) && {getGroupName(gp.id)} } +
{gp.filters.map(getFilterDisplay)}
+
); })} @@ -166,7 +434,7 @@ export const FiltersChips: React.FC = ({ resetFilters(); autoCompleteCache.clear(); }, - enabled: defaultFilters.length > 0 && !isDefaultFilters + enabled: getDefaultFilters().length > 0 && !isDefaultFilters }, { id: 'clear-all-filters', @@ -175,39 +443,14 @@ export const FiltersChips: React.FC = ({ clearFilters(); autoCompleteCache.clear(); }, - enabled: !_.isEmpty(chipFilters) + enabled: !_.isEmpty(filters.list) }, { id: 'swap-filters', label: t('Swap'), - tooltip: t('Swap source and destination filters'), - onClick: swapSrcDst, - enabled: isSrcDst - }, - { - id: 'back-and-forth', - label: filters?.backAndForth ? t('Back and forth') : t('One way'), - onClick: toggleBackAndForth, - icon: filters?.backAndForth ? ( - <> - - - - ) : ( - - ), - tooltip: ( - - {t('Switch between one way / back and forth filtering')} - - - {t('One way shows traffic strictly as defined per your filters')} - - - - {t('Back and forth shows traffic according to your filters, plus the related return traffic')} - - - ), - enabled: isSrcDst + tooltip: t('Swap from and to filters'), + onClick: swapAllSrcDst, + enabled: hasSrcOrDstFilters(filters.list!) && filters.match !== 'peers' } ]} /> diff --git a/web/src/components/toolbar/filters/filters-dropdown.css b/web/src/components/toolbar/filters/filters-dropdown.css index 0ae265f90..8a8a470c0 100644 --- a/web/src/components/toolbar/filters/filters-dropdown.css +++ b/web/src/components/toolbar/filters/filters-dropdown.css @@ -1,3 +1,7 @@ -#column-filter-dropdown { - min-width: 270px !important; +#direction-column-filter-dropdowns { + min-width: 370px; +} + +#direction-filter-dropdown-container, #column-filter-dropdown-container { + flex: 1; } \ No newline at end of file diff --git a/web/src/components/toolbar/filters/filters-dropdown.tsx b/web/src/components/toolbar/filters/filters-dropdown.tsx index 080ece985..114f82ec7 100644 --- a/web/src/components/toolbar/filters/filters-dropdown.tsx +++ b/web/src/components/toolbar/filters/filters-dropdown.tsx @@ -1,96 +1,157 @@ -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionToggle, - Dropdown, - DropdownItem, - MenuToggle, - MenuToggleElement -} from '@patternfly/react-core'; +import { Dropdown, DropdownItem, Flex, MenuToggle, MenuToggleElement } from '@patternfly/react-core'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import { FilterDefinition } from '../../../model/filters'; -import { buildGroups, getFilterFullName } from '../../../utils/filters-helper'; +import { FilterDefinition, FilterId } from '../../../model/filters'; +import { findFilter } from '../../../utils/filter-definitions'; +import { swapFilterDefinition } from '../../../utils/filters-helper'; import { useOutsideClickEvent } from '../../../utils/outside-hook'; import './filters-dropdown.css'; export interface FiltersDropdownProps { filterDefinitions: FilterDefinition[]; + selectedDirection?: 'source' | 'destination'; + setSelectedDirection: (v?: 'source' | 'destination') => void; selectedFilter: FilterDefinition; setSelectedFilter: (f: FilterDefinition) => void; } export const FiltersDropdown: React.FC = ({ filterDefinitions, + selectedDirection, + setSelectedDirection, selectedFilter, setSelectedFilter }) => { const { t } = useTranslation('plugin__netobserv-plugin'); - const groups = buildGroups(filterDefinitions, t); - const ref = useOutsideClickEvent(() => setOpen(false)); - const [isOpen, setOpen] = React.useState(false); - const [expandedGroup, setExpandedGroup] = React.useState(0); + + const directionRef = useOutsideClickEvent(() => setDirectionOpen(false)); + const [isDirectionOpen, setDirectionOpen] = React.useState(false); + + const columnRef = useOutsideClickEvent(() => setColumnOpen(false)); + const [isColumnOpen, setColumnOpen] = React.useState(false); const getFiltersDropdownItems = () => { - return [ - - {groups.map((g, i) => ( - - setExpandedGroup(expandedGroup !== i ? i : -1)} - isExpanded={expandedGroup === i} - data-test={`group-${i}-toggle`} - id={`group-${i}-toggle`} - > - {g.title &&

{g.title}

} -
- - {g.filters.map((f, index) => ( - { - setOpen(false); - setSelectedFilter(f); - }} - key={index} - > - {f.name} - - ))} - -
- ))} -
- ]; + return filterDefinitions + .filter(f => (selectedDirection ? f.category === selectedDirection : !f.category || f.category === 'targeteable')) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((f, index) => ( + { + setColumnOpen(false); + setSelectedFilter(f); + }} + key={index} + > + {f.name} + + )); }; + React.useEffect(() => { + if (selectedDirection) { + const dir = selectedDirection === 'source' ? 'src' : 'dst'; + if (selectedFilter.category) { + setSelectedFilter(swapFilterDefinition(filterDefinitions, selectedFilter, dir)); + } else { + setSelectedFilter(findFilter(filterDefinitions, `${dir}_namespace`)!); + } + } else if (selectedFilter.id.startsWith('src_') || selectedFilter.id.startsWith('dst_')) { + const id = selectedFilter.id.replace('src_', '').replace('dst_', '') as FilterId; + setSelectedFilter(findFilter(filterDefinitions, id)!); + } + }, [filterDefinitions, selectedDirection, selectedFilter, setSelectedFilter]); + return ( -
- ) => ( - +
+ ) => ( + { + setDirectionOpen(!isDirectionOpen); + }} + isExpanded={isDirectionOpen} + > + {selectedDirection === 'source' + ? t('Source') + : selectedDirection === 'destination' + ? t('Destination') + : t('Common')} + + )} + > + { + setDirectionOpen(false); + setSelectedDirection('source'); + }} + > + {t('Source')} + + { + setDirectionOpen(false); + setSelectedDirection('destination'); + }} + > + {t('Destination')} + + { - setOpen(!isOpen); + setDirectionOpen(false); + setSelectedDirection(undefined); }} - isExpanded={isOpen} > - {getFilterFullName(selectedFilter, t)} - - )} - > - {getFiltersDropdownItems()} - -
+ {t('Common')} + +
+
+
+ ) => ( + { + setColumnOpen(!isColumnOpen); + }} + isExpanded={isColumnOpen} + > + {selectedFilter.name} + + )} + > + {getFiltersDropdownItems()} + +
+ ); }; diff --git a/web/src/components/toolbar/filters/summary-filter-button.tsx b/web/src/components/toolbar/filters/summary-filter-button.tsx index 3051196e8..a011f3832 100644 --- a/web/src/components/toolbar/filters/summary-filter-button.tsx +++ b/web/src/components/toolbar/filters/summary-filter-button.tsx @@ -3,7 +3,7 @@ import { FilterIcon } from '@patternfly/react-icons'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { TopologyMetricPeer } from '../../../api/loki'; -import { Filter, FilterDefinition } from '../../../model/filters'; +import { Filter, FilterDefinition, Filters } from '../../../model/filters'; import { NodeType } from '../../../model/flow-query'; import { FilterDir, isDirElementFiltered, toggleDirElementFilter } from '../../../model/topology'; import { useOutsideClickEvent } from '../../../utils/outside-hook'; @@ -13,7 +13,7 @@ export interface SummaryFilterButtonProps { id: string; filterType: NodeType; fields: Partial; - activeFilters: Filter[]; + filters: Filters; setFilters: (filters: Filter[]) => void; filterDefinitions: FilterDefinition[]; } @@ -25,7 +25,7 @@ export const SummaryFilterButton: React.FC = ({ id, filterType, fields, - activeFilters, + filters, setFilters, filterDefinitions }) => { @@ -33,7 +33,7 @@ export const SummaryFilterButton: React.FC = ({ const ref = useOutsideClickEvent(() => setOpen(false)); const [isOpen, setOpen] = React.useState(false); const selected = [srcFilter, dstFilter].filter(dir => - isDirElementFiltered(filterType, fields, dir, activeFilters, filterDefinitions) + isDirElementFiltered(filterType, fields, dir, filters.list, filterDefinitions) ); const onSelect = (dir: FilterDir, e?: React.BaseSyntheticEvent) => { @@ -42,7 +42,7 @@ export const SummaryFilterButton: React.FC = ({ fields, dir, selected.includes(dir), - activeFilters, + filters.list, setFilters, filterDefinitions ); @@ -78,8 +78,8 @@ export const SummaryFilterButton: React.FC = ({ onSelect={(event, value) => value && onSelect(value as FilterDir, event)} > - {menuItem('src', t('Source'))} - {menuItem('dst', t('Destination'))} + {menuItem('src', filters.match === 'peers' ? t('Peer A') : t('Source'))} + {menuItem('dst', filters.match === 'peers' ? t('Peer B') : t('Destination'))}
diff --git a/web/src/components/toolbar/filters/text-filter.tsx b/web/src/components/toolbar/filters/text-filter.tsx index 6f5ec6bf4..14c839be2 100644 --- a/web/src/components/toolbar/filters/text-filter.tsx +++ b/web/src/components/toolbar/filters/text-filter.tsx @@ -1,5 +1,4 @@ -import { Button, TextInput, ValidatedOptions } from '@patternfly/react-core'; -import { SearchIcon } from '@patternfly/react-icons'; +import { TextInput, ValidatedOptions } from '@patternfly/react-core'; import * as _ from 'lodash'; import * as React from 'react'; import { createFilterValue, FilterDefinition, FilterValue } from '../../../model/filters'; @@ -9,34 +8,40 @@ import { Indicator } from '../../../utils/filters-helper'; export interface TextFilterProps { filterDefinition: FilterDefinition; addFilter: (filter: FilterValue) => boolean; - setMessageWithDelay: (m: string | undefined) => void; + setMessage: (m: string | undefined) => void; indicator: Indicator; setIndicator: (ind: Indicator) => void; allowEmpty?: boolean; regexp?: RegExp; + currentValue: string; + setCurrentValue: (v: string) => void; } export const TextFilter: React.FC = ({ filterDefinition, addFilter, - setMessageWithDelay, + setMessage, indicator, setIndicator, allowEmpty, - regexp + regexp, + currentValue, + setCurrentValue }) => { const searchInputRef = React.useRef(null); - const [currentValue, setCurrentValue] = React.useState(''); - React.useEffect(() => { - //update validation icon on field on value change - if (!_.isEmpty(currentValue)) { - const validation = filterDefinition.validate(String(currentValue)); - setIndicator(!_.isEmpty(validation.err) ? ValidatedOptions.warning : ValidatedOptions.success); - } else { - setIndicator(ValidatedOptions.default); - } - }, [currentValue, filterDefinition, setIndicator]); + const updateIndicator = React.useCallback( + (v = currentValue) => { + //update validation icon on field on value change + if (!_.isEmpty(v)) { + const validation = filterDefinition.validate(String(v)); + setIndicator(!_.isEmpty(validation.err) ? ValidatedOptions.warning : ValidatedOptions.success); + } else { + setIndicator(ValidatedOptions.default); + } + }, + [currentValue, filterDefinition, setIndicator] + ); const updateValue = React.useCallback( (v: string) => { @@ -45,17 +50,18 @@ export const TextFilter: React.FC = ({ filteredValue = filteredValue.replace(regexp, ''); } setCurrentValue(filteredValue); + updateIndicator(filteredValue); }, - [regexp] + [regexp, setCurrentValue, updateIndicator] ); const resetFilterValue = React.useCallback(() => { setCurrentValue(''); - setMessageWithDelay(undefined); + setMessage(undefined); setIndicator(ValidatedOptions.default); - }, [setCurrentValue, setMessageWithDelay, setIndicator]); + }, [setCurrentValue, setMessage, setIndicator]); - const onSelect = React.useCallback(() => { + const onEnter = React.useCallback(() => { // override empty value by undefined value let v = currentValue; if (allowEmpty) { @@ -69,7 +75,7 @@ export const TextFilter: React.FC = ({ const validation = filterDefinition.validate(String(v)); //show tooltip and icon when user try to validate filter if (!_.isEmpty(validation.err)) { - setMessageWithDelay(validation.err); + setMessage(validation.err); setIndicator(ValidatedOptions.error); return; } @@ -79,25 +85,24 @@ export const TextFilter: React.FC = ({ resetFilterValue(); } }); - }, [currentValue, allowEmpty, filterDefinition, setMessageWithDelay, setIndicator, addFilter, resetFilterValue]); + }, [currentValue, allowEmpty, filterDefinition, setMessage, setIndicator, addFilter, resetFilterValue]); + + React.useEffect(() => { + updateIndicator(); + }, [currentValue, updateIndicator]); return ( - <> - updateValue(value)} - onKeyPress={e => e.key === 'Enter' && onSelect()} - value={currentValue} - ref={searchInputRef} - id="search" - /> - - + updateValue(value)} + onKeyDown={e => e.key === 'Enter' && onEnter()} + value={currentValue} + ref={searchInputRef} + id="search" + /> ); }; diff --git a/web/src/components/toolbar/view-options-toolbar.tsx b/web/src/components/toolbar/view-options-toolbar.tsx index c668b4d3e..3701dbad7 100644 --- a/web/src/components/toolbar/view-options-toolbar.tsx +++ b/web/src/components/toolbar/view-options-toolbar.tsx @@ -16,7 +16,7 @@ import { import { ColumnsIcon, EllipsisVIcon, ExportIcon } from '@patternfly/react-icons'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import { FlowScope, MetricType, StatFunction } from 'src/model/flow-query'; +import { FlowScope, MetricType, StatFunction } from '../../model/flow-query'; import { ScopeConfigDef } from '../../model/scope'; import { TopologyOptions } from '../../model/topology'; import { exportToPng } from '../../utils/export'; diff --git a/web/src/model/__tests__/filters.spec.ts b/web/src/model/__tests__/filters.spec.ts index c822b72d3..c4e53b161 100644 --- a/web/src/model/__tests__/filters.spec.ts +++ b/web/src/model/__tests__/filters.spec.ts @@ -1,6 +1,6 @@ import { FilterDefinitionSample } from '../../components/__tests-data__/filters'; import { findFilter } from '../../utils/filter-definitions'; -import { doesIncludeFilter, Filter, filtersEqual } from '../filters'; +import { doesIncludeFilter, Filter, FilterCompare, filtersEqual } from '../filters'; import { filtersToString } from '../flow-query'; describe('doesIncludeFilter', () => { @@ -9,12 +9,12 @@ describe('doesIncludeFilter', () => { const activeFilters: Filter[] = [ { def: srcNameFilter, - not: false, + compare: FilterCompare.equal, values: [{ v: 'abc' }, { v: 'def' }] }, { def: notDstNameFilter, - not: true, + compare: FilterCompare.notEqual, values: [{ v: 'abc' }, { v: 'def' }] } ]; @@ -25,15 +25,16 @@ describe('doesIncludeFilter', () => { }); it('should not include filter due to different key', () => { - const isIncluded = doesIncludeFilter(activeFilters, { def: findFilter(FilterDefinitionSample, 'protocol')! }, [ - { v: 'abc' }, - { v: 'def' } - ]); + const isIncluded = doesIncludeFilter( + activeFilters, + { def: findFilter(FilterDefinitionSample, 'protocol')!, compare: FilterCompare.equal }, + [{ v: 'abc' }, { v: 'def' }] + ); expect(isIncluded).toBeFalsy(); }); it('should not include filter due to missing value', () => { - const isIncluded = doesIncludeFilter(activeFilters, { def: srcNameFilter, not: false }, [ + const isIncluded = doesIncludeFilter(activeFilters, { def: srcNameFilter, compare: FilterCompare.equal }, [ { v: 'abc' }, { v: 'def' }, { v: 'ghi' } @@ -42,7 +43,7 @@ describe('doesIncludeFilter', () => { }); it('should include filter with exact values', () => { - const isIncluded = doesIncludeFilter(activeFilters, { def: srcNameFilter, not: false }, [ + const isIncluded = doesIncludeFilter(activeFilters, { def: srcNameFilter, compare: FilterCompare.equal }, [ { v: 'abc' }, { v: 'def' } ]); @@ -50,12 +51,14 @@ describe('doesIncludeFilter', () => { }); it('should include filter with values included', () => { - const isIncluded = doesIncludeFilter(activeFilters, { def: srcNameFilter, not: false }, [{ v: 'abc' }]); + const isIncluded = doesIncludeFilter(activeFilters, { def: srcNameFilter, compare: FilterCompare.equal }, [ + { v: 'abc' } + ]); expect(isIncluded).toBeTruthy(); }); it('should not include filter due to different key (not)', () => { - const isIncluded = doesIncludeFilter(activeFilters, { def: notDstNameFilter, not: false }, [ + const isIncluded = doesIncludeFilter(activeFilters, { def: notDstNameFilter, compare: FilterCompare.equal }, [ { v: 'abc' }, { v: 'def' } ]); @@ -63,7 +66,7 @@ describe('doesIncludeFilter', () => { }); it('should include filter with same key (not)', () => { - const isIncluded = doesIncludeFilter(activeFilters, { def: notDstNameFilter, not: true }, [ + const isIncluded = doesIncludeFilter(activeFilters, { def: notDstNameFilter, compare: FilterCompare.notEqual }, [ { v: 'abc' }, { v: 'def' } ]); @@ -81,12 +84,12 @@ describe('filtersEqual', () => { it('should be equal with same order', () => { const list1: Filter[] = [ - { def: f1, not: false, values: values1 }, - { def: f2, not: true, values: values1 } + { def: f1, compare: FilterCompare.equal, values: values1 }, + { def: f2, compare: FilterCompare.notEqual, values: values1 } ]; const list2: Filter[] = [ - { def: f1, not: false, values: values1 }, - { def: f2, not: true, values: values1 } + { def: f1, compare: FilterCompare.equal, values: values1 }, + { def: f2, compare: FilterCompare.notEqual, values: values1 } ]; expect(filtersEqual(list1, list2)).toBe(true); expect(filtersEqual(list2, list1)).toBe(true); @@ -94,12 +97,12 @@ describe('filtersEqual', () => { it('should be equal with different order', () => { const list1: Filter[] = [ - { def: f1, not: false, values: values1 }, - { def: f2, not: true, values: values1 } + { def: f1, compare: FilterCompare.equal, values: values1 }, + { def: f2, compare: FilterCompare.notEqual, values: values1 } ]; const list2: Filter[] = [ - { def: f2, not: true, values: values1 }, - { def: f1, not: false, values: values1 } + { def: f2, compare: FilterCompare.notEqual, values: values1 }, + { def: f1, compare: FilterCompare.equal, values: values1 } ]; expect(filtersEqual(list1, list2)).toBe(true); expect(filtersEqual(list2, list1)).toBe(true); @@ -107,12 +110,12 @@ describe('filtersEqual', () => { it('should be equal with different values order', () => { const list1: Filter[] = [ - { def: f1, not: false, values: values1 }, - { def: f2, not: true, values: values1 } + { def: f1, compare: FilterCompare.equal, values: values1 }, + { def: f2, compare: FilterCompare.notEqual, values: values1 } ]; const list2: Filter[] = [ - { def: f1, not: false, values: values2 }, - { def: f2, not: true, values: values2 } + { def: f1, compare: FilterCompare.equal, values: values2 }, + { def: f2, compare: FilterCompare.notEqual, values: values2 } ]; expect(filtersEqual(list1, list2)).toBe(true); expect(filtersEqual(list2, list1)).toBe(true); @@ -120,12 +123,12 @@ describe('filtersEqual', () => { it('should be equal with different values display', () => { const list1: Filter[] = [ - { def: f1, not: false, values: values1 }, - { def: f2, not: true, values: values1 } + { def: f1, compare: FilterCompare.equal, values: values1 }, + { def: f2, compare: FilterCompare.notEqual, values: values1 } ]; const list2: Filter[] = [ - { def: f1, not: false, values: values3 }, - { def: f2, not: true, values: values3 } + { def: f1, compare: FilterCompare.equal, values: values3 }, + { def: f2, compare: FilterCompare.notEqual, values: values3 } ]; expect(filtersEqual(list1, list2)).toBe(true); expect(filtersEqual(list2, list1)).toBe(true); @@ -133,12 +136,12 @@ describe('filtersEqual', () => { it('should differ with different keys', () => { const list1: Filter[] = [ - { def: f1, not: false, values: values1 }, - { def: f2, not: true, values: values1 } + { def: f1, compare: FilterCompare.equal, values: values1 }, + { def: f2, compare: FilterCompare.notEqual, values: values1 } ]; const list2: Filter[] = [ - { def: f1, not: false, values: values1 }, - { def: f1, not: true, values: values1 } + { def: f1, compare: FilterCompare.equal, values: values1 }, + { def: f1, compare: FilterCompare.notEqual, values: values1 } ]; expect(filtersEqual(list1, list2)).toBe(false); expect(filtersEqual(list2, list1)).toBe(false); @@ -146,12 +149,12 @@ describe('filtersEqual', () => { it('should differ with different values', () => { const list1: Filter[] = [ - { def: f1, not: false, values: values1 }, - { def: f2, not: true, values: values1 } + { def: f1, compare: FilterCompare.equal, values: values1 }, + { def: f2, compare: FilterCompare.notEqual, values: values1 } ]; const list2: Filter[] = [ - { def: f1, not: false, values: values1 }, - { def: f2, not: true, values: values4 } + { def: f1, compare: FilterCompare.equal, values: values1 }, + { def: f2, compare: FilterCompare.notEqual, values: values4 } ]; expect(filtersEqual(list1, list2)).toBe(false); expect(filtersEqual(list2, list1)).toBe(false); diff --git a/web/src/model/filters.ts b/web/src/model/filters.ts index 88029af36..bdb942bb5 100644 --- a/web/src/model/filters.ts +++ b/web/src/model/filters.ts @@ -1,12 +1,38 @@ import _ from 'lodash'; +import { TFunction } from 'react-i18next'; import { isEqual } from '../utils/base-compare'; import { undefinedValue } from '../utils/filter-definitions'; +import { Match } from './flow-query'; -export type FiltersEncoder = (values: FilterValue[], matchAny: boolean, not: boolean, moreThan: boolean) => string; +export type FiltersEncoder = (values: FilterValue[], compare: FilterCompare, matchAny: boolean) => string; export type FilterComponent = 'autocomplete' | 'text' | 'number'; -export type FilterCategory = 'source' | 'destination'; +export type FilterCategory = 'source' | 'destination' | 'targeteable'; + +export enum FilterCompare { + match = '~', + notMatch = '!~', + equal = '=', + notEqual = '!=', + moreThanOrEqual = '>=' +} + +export const getCompareText = (v: FilterCompare, t: TFunction) => { + switch (v) { + case FilterCompare.match: + return t('Contains'); + case FilterCompare.notMatch: + return t('Not contains'); + case FilterCompare.notEqual: + return t('Not equals'); + case FilterCompare.moreThanOrEqual: + return t('More than'); + case FilterCompare.equal: + default: + return t('Equals'); + } +}; export type TargetedFilterId = | 'zone' @@ -23,6 +49,7 @@ export type TargetedFilterId = export type FilterId = | 'cluster_name' + | TargetedFilterId | `src_${TargetedFilterId}` | `dst_${TargetedFilterId}` | 'protocol' @@ -86,14 +113,13 @@ export interface FilterValue { export interface Filter { def: FilterDefinition; - not?: boolean; - moreThan?: boolean; + compare: FilterCompare; values: FilterValue[]; } export interface Filters { list: Filter[]; - backAndForth: boolean; + match: Match; } export interface FilterOption { @@ -136,13 +162,16 @@ export const getEnabledFilters = (filters: Filters): Filters => { return f; }) .filter(f => !_.isEmpty(f.values)), - backAndForth: filters.backAndForth + match: filters.match }; }; export type DisabledFilters = Record; -export const filterKey = (filter: Filter) => filter.def.id + (filter.not ? '!' : '') + (filter.moreThan ? '>' : ''); +export const filterKey = (filter: Filter) => + filter.def.id + + ([FilterCompare.notEqual, FilterCompare.notMatch].includes(filter.compare) ? '!' : '') + + (filter.compare === FilterCompare.moreThanOrEqual ? '>' : ''); export const fromFilterKey = (key: string) => { if (key.endsWith('!')) { @@ -190,18 +219,14 @@ export const removeFromFilters = (activeFilters: Filter[], search: FilterKey): F }; export const filterKeyEqual = (f1: FilterKey, f2: FilterKey): boolean => { - return ( - f1.def.id === f2.def.id && - (f1.not === true) === (f2.not === true) && - (f1.moreThan === true) === (f2.moreThan === true) - ); + return f1.def.id === f2.def.id && f1.compare === f2.compare; }; type ComparableFilter = { key: string; values: string[] }; const comparableFilter = (f: Filter): ComparableFilter => { return { - key: f.def.id + (f.not ? '!' : ''), + key: f.def.id + ([FilterCompare.notEqual, FilterCompare.notMatch].includes(f.compare) ? '!' : ''), values: f.values.map(v => v.v).sort() }; }; diff --git a/web/src/model/flow-query.ts b/web/src/model/flow-query.ts index 4bc14a411..b0197b938 100644 --- a/web/src/model/flow-query.ts +++ b/web/src/model/flow-query.ts @@ -3,7 +3,7 @@ import { Filter } from './filters'; export type RecordType = 'allConnections' | 'newConnection' | 'heartbeat' | 'endConnection' | 'flowLog'; export type DataSource = 'auto' | 'loki' | 'prom'; -export type Match = 'all' | 'any'; +export type Match = 'any' | 'all' | 'peers'; export type PacketLoss = 'dropped' | 'hasDrops' | 'sent' | 'all'; export type MetricFunction = 'count' | 'sum' | 'avg' | 'min' | 'max' | 'p90' | 'p99' | 'rate'; export type StatFunction = MetricFunction | 'last'; @@ -43,7 +43,7 @@ export interface FlowQuery { export const filtersToString = (filters: Filter[], matchAny: boolean): string => { const matches: string[] = []; filters.forEach(f => { - const str = f.def.encoder(f.values, matchAny, f.not || false, f.moreThan || false); + const str = f.def.encoder(f.values, f.compare, matchAny); matches.push(str); }); return encodeURIComponent(matches.join(matchAny ? '|' : '&')); diff --git a/web/src/model/netflow-traffic.ts b/web/src/model/netflow-traffic.ts index 38808cef0..8d496920e 100644 --- a/web/src/model/netflow-traffic.ts +++ b/web/src/model/netflow-traffic.ts @@ -42,7 +42,6 @@ import { defaultMetricType, getDataSourceFromURL, getLimitFromURL, - getMatchFromURL, getPacketLossFromURL, getRangeFromURL, getRecordTypeFromURL, @@ -50,16 +49,7 @@ import { } from '../utils/router'; import { Config, defaultConfig } from './config'; import { DisabledFilters, Filters } from './filters'; -import { - DataSource, - FlowScope, - isTimeMetric, - Match, - MetricType, - PacketLoss, - RecordType, - StatFunction -} from './flow-query'; +import { DataSource, FlowScope, isTimeMetric, MetricType, PacketLoss, RecordType, StatFunction } from './flow-query'; import { getGroupsForScope } from './scope'; import { DefaultOptions, GraphElementPeer, TopologyOptions } from './topology'; @@ -129,8 +119,7 @@ export function netflowTrafficModel() { const [isOverviewModalOpen, setOverviewModalOpen] = React.useState(false); const [isColModalOpen, setColModalOpen] = React.useState(false); const [isExportModalOpen, setExportModalOpen] = React.useState(false); - const [filters, setFilters] = React.useState({ list: [], backAndForth: false }); - const [match, setMatch] = React.useState(getMatchFromURL()); + const [filters, setFilters] = React.useState({ list: [], match: 'all' }); const [packetLoss, setPacketLoss] = React.useState(getPacketLossFromURL()); const [recordType, setRecordType] = React.useState(getRecordTypeFromURL()); const [dataSource, setDataSource] = React.useState(getDataSourceFromURL()); @@ -259,8 +248,6 @@ export function netflowTrafficModel() { setExportModalOpen, filters, setFilters, - match, - setMatch, packetLoss, setPacketLoss, recordType, diff --git a/web/src/model/quick-filters.ts b/web/src/model/quick-filters.ts index 4a9b7096e..8ba2c6fa1 100644 --- a/web/src/model/quick-filters.ts +++ b/web/src/model/quick-filters.ts @@ -1,5 +1,5 @@ import { findFilter } from '../utils/filter-definitions'; -import { Filter, FilterDefinition, fromFilterKey } from './filters'; +import { Filter, FilterCompare, FilterDefinition, fromFilterKey } from './filters'; export type RawQuickFilter = { name: string; @@ -25,8 +25,8 @@ export const parseQuickFilters = (filterDefinitions: FilterDefinition[], raw: Ra } const filter: Filter = { def: def, - not: not, - moreThan: moreThan, + // rely on match here since it allows to use quotes for exact match + compare: moreThan ? FilterCompare.moreThanOrEqual : not ? FilterCompare.notMatch : FilterCompare.match, values: values.split(',').map(v => ({ v: v })) }; return filter; diff --git a/web/src/model/topology.ts b/web/src/model/topology.ts index 75036b6b8..fe86e0dd5 100644 --- a/web/src/model/topology.ts +++ b/web/src/model/topology.ts @@ -17,13 +17,13 @@ import { TFunction } from 'i18next'; import _ from 'lodash'; import { MetricStats, TopologyMetricPeer, TopologyMetrics } from '../api/loki'; import { TruncateLength } from '../components/dropdowns/truncate-dropdown'; -import { Filter, FilterDefinition, FilterId, Filters, findFromFilters } from '../model/filters'; +import { Filter, FilterCompare, FilterDefinition, FilterId, Filters, findFromFilters } from '../model/filters'; import { ContextSingleton } from '../utils/context'; import { findFilter } from '../utils/filter-definitions'; import { getTopologyEdgeId } from '../utils/ids'; import { createPeer, getFormattedValue } from '../utils/metrics'; import { defaultMetricFunction, defaultMetricType } from '../utils/router'; -import { FlowScope, Groups, MetricFunction, MetricType, NodeType, StatFunction } from './flow-query'; +import { FlowScope, Groups, Match, MetricFunction, MetricType, NodeType, StatFunction } from './flow-query'; import { getStat } from './metrics'; import { getStepInto, isDirectionnal, ScopeConfigDef } from './scope'; @@ -82,6 +82,7 @@ export type Decorated = T & { hover?: boolean; dragging?: boolean; highlighted?: boolean; + match: Match; isSrcFiltered?: boolean; isDstFiltered?: boolean; isClearFilters?: boolean; @@ -156,7 +157,7 @@ export const isDirElementFiltered = ( if (!defValue) { return false; } - const filter = findFromFilters(filters, { def: defValue.def }); + const filter = findFromFilters(filters, { def: defValue.def, compare: FilterCompare.equal }); return filter !== undefined && filter.values.find(v => v.v === defValue.value) !== undefined; }; @@ -169,7 +170,7 @@ export const isElementFiltered = ( if (!defValue) { return false; } - const filter = findFromFilters(filters, { def: defValue.def }); + const filter = findFromFilters(filters, { def: defValue.def, compare: FilterCompare.equal }); return filter !== undefined && filter.values.find(v => v.v === defValue.value) !== undefined; }; @@ -182,9 +183,9 @@ const toggleFilter = ( isFiltered: boolean, setFilters: (filters: Filter[]) => void ) => { - let filter = findFromFilters(result, { def: defValue.def }); + let filter = findFromFilters(result, { def: defValue.def, compare: FilterCompare.equal }); if (!filter) { - filter = { def: defValue.def, values: [] }; + filter = { def: defValue.def, compare: FilterCompare.equal, values: [] }; result.push(filter); } if (isFiltered) { @@ -284,6 +285,7 @@ const generateNode = ( filtered, highlighted, isDark, + match: filters.match, isSrcFiltered, isDstFiltered, labelPosition: LabelPosition.bottom, diff --git a/web/src/utils/__tests__/back-and-forth.spec.ts b/web/src/utils/__tests__/back-and-forth.spec.ts index 5e7cac41e..f5f26d2d4 100644 --- a/web/src/utils/__tests__/back-and-forth.spec.ts +++ b/web/src/utils/__tests__/back-and-forth.spec.ts @@ -2,7 +2,7 @@ import { FlowMetricsResult, RawTopologyMetrics } from '../../api/loki'; import { getFlowMetrics, getFlowRecords } from '../../api/routes'; import { FilterDefinitionSample } from '../../components/__tests-data__/filters'; import { ScopeDefSample } from '../../components/__tests-data__/scopes'; -import { Filter, FilterId, Filters, FilterValue } from '../../model/filters'; +import { Filter, FilterCompare, FilterId, Filters, FilterValue } from '../../model/filters'; import { filtersToString } from '../../model/flow-query'; import { getFetchFunctions, mergeMetricsBNF } from '../back-and-forth'; import { ContextSingleton } from '../context'; @@ -19,8 +19,8 @@ const getFlowMetricsMock = getFlowMetrics as jest.Mock; const filter = (id: FilterId, values: FilterValue[], not?: boolean): Filter => { return { def: findFilter(FilterDefinitionSample, id)!, - values: values, - not: not + compare: not ? FilterCompare.notEqual : FilterCompare.equal, + values: values }; }; @@ -47,7 +47,7 @@ describe('Match all, flows', () => { it('should encode', () => { const filters = getEncodedFilter( - { list: [filter('src_name', [{ v: 'test1' }, { v: 'test2' }])], backAndForth: false }, + { list: [filter('src_name', [{ v: 'test1' }, { v: 'test2' }])], match: 'all' }, false ); expect(filters).toEqual('SrcK8S_Name%3Dtest1%2Ctest2'); @@ -57,7 +57,7 @@ describe('Match all, flows', () => { const grouped = getDecodedFilter( { list: [filter('src_name', [{ v: 'test1' }, { v: 'test2' }]), filter('src_namespace', [{ v: 'ns' }])], - backAndForth: false + match: 'all' }, false ); @@ -68,7 +68,7 @@ describe('Match all, flows', () => { const grouped = getDecodedFilter( { list: [filter('src_name', [{ v: 'test1' }, { v: 'test2' }]), filter('dst_port', [{ v: '443' }])], - backAndForth: true + match: 'peers' }, false ); @@ -79,7 +79,7 @@ describe('Match all, flows', () => { const grouped = getDecodedFilter( { list: [filter('src_namespace', [{ v: 'ns' }]), filter('dst_owner_name', [{ v: 'test' }])], - backAndForth: true + match: 'peers' }, false ); @@ -95,7 +95,7 @@ describe('Match all, flows', () => { filter('src_kind', [{ v: 'Pod' }]), filter('protocol', [{ v: '6' }]) ], - backAndForth: true + match: 'peers' }, false ); @@ -105,18 +105,12 @@ describe('Match all, flows', () => { }); it('should generate simple Src K8S resource', () => { - const grouped = getDecodedFilter( - { list: [filter('src_resource', [{ v: 'Pod.ns.test' }])], backAndForth: false }, - false - ); + const grouped = getDecodedFilter({ list: [filter('src_resource', [{ v: 'Pod.ns.test' }])], match: 'all' }, false); expect(grouped).toEqual('SrcK8S_Type="Pod"&SrcK8S_Namespace="ns"&SrcK8S_Name="test"'); }); it('should generate K8S resource, back and forth', () => { - const grouped = getDecodedFilter( - { list: [filter('src_resource', [{ v: 'Pod.ns.test' }])], backAndForth: true }, - false - ); + const grouped = getDecodedFilter({ list: [filter('src_resource', [{ v: 'Pod.ns.test' }])], match: 'peers' }, false); expect(grouped).toEqual( 'SrcK8S_Type="Pod"&SrcK8S_Namespace="ns"&SrcK8S_Name="test"' + '|DstK8S_Type="Pod"&DstK8S_Namespace="ns"&DstK8S_Name="test"' @@ -124,10 +118,7 @@ describe('Match all, flows', () => { }); it('should generate Node Src/Dst K8S resource, back and forth', () => { - const grouped = getDecodedFilter( - { list: [filter('src_resource', [{ v: 'Node.test' }])], backAndForth: true }, - false - ); + const grouped = getDecodedFilter({ list: [filter('src_resource', [{ v: 'Node.test' }])], match: 'peers' }, false); expect(grouped).toEqual( 'SrcK8S_Type="Node"&SrcK8S_Namespace=""&SrcK8S_Name="test"' + '|DstK8S_Type="Node"&DstK8S_Namespace=""&DstK8S_Name="test"' @@ -136,7 +127,7 @@ describe('Match all, flows', () => { it('should generate Owner Src/Dst K8S resource, back and forth', () => { const grouped = getDecodedFilter( - { list: [filter('src_resource', [{ v: 'DaemonSet.ns.test' }])], backAndForth: true }, + { list: [filter('src_resource', [{ v: 'DaemonSet.ns.test' }])], match: 'peers' }, false ); expect(grouped).toEqual( @@ -149,7 +140,7 @@ describe('Match all, flows', () => { const grouped = getDecodedFilter( { list: [filter('src_resource', [{ v: 'Pod.ns.test' }]), filter('dst_name', [{ v: 'peer' }])], - backAndForth: true + match: 'peers' }, false ); @@ -167,7 +158,7 @@ describe('Match any, flows', () => { it('should encode', () => { const grouped = getEncodedFilter( - { list: [filter('src_name', [{ v: 'test1' }, { v: 'test2' }])], backAndForth: false }, + { list: [filter('src_name', [{ v: 'test1' }, { v: 'test2' }])], match: 'all' }, true ); expect(grouped).toEqual('SrcK8S_Name%3Dtest1%2Ctest2'); @@ -177,7 +168,7 @@ describe('Match any, flows', () => { const grouped = getDecodedFilter( { list: [filter('src_name', [{ v: 'test1' }, { v: 'test2' }]), filter('src_namespace', [{ v: 'ns' }])], - backAndForth: false + match: 'all' }, true ); @@ -188,7 +179,7 @@ describe('Match any, flows', () => { const grouped = getDecodedFilter( { list: [filter('src_name', [{ v: 'test1' }, { v: 'test2' }]), filter('dst_port', [{ v: '443' }])], - backAndForth: true + match: 'peers' }, true ); @@ -204,7 +195,7 @@ describe('Match any, flows', () => { filter('src_kind', [{ v: 'Pod' }]), filter('protocol', [{ v: '6' }]) ], - backAndForth: true + match: 'peers' }, true ); @@ -214,18 +205,12 @@ describe('Match any, flows', () => { }); it('should generate simple Src K8S resource', () => { - const grouped = getDecodedFilter( - { list: [filter('src_resource', [{ v: 'Pod.ns.test' }])], backAndForth: false }, - true - ); + const grouped = getDecodedFilter({ list: [filter('src_resource', [{ v: 'Pod.ns.test' }])], match: 'all' }, true); expect(grouped).toEqual('SrcK8S_Type="Pod"&SrcK8S_Namespace="ns"&SrcK8S_Name="test"'); }); it('should generate K8S resource, back and forth', () => { - const grouped = getDecodedFilter( - { list: [filter('src_resource', [{ v: 'Pod.ns.test' }])], backAndForth: true }, - true - ); + const grouped = getDecodedFilter({ list: [filter('src_resource', [{ v: 'Pod.ns.test' }])], match: 'peers' }, true); expect(grouped).toEqual( 'SrcK8S_Type="Pod"&SrcK8S_Namespace="ns"&SrcK8S_Name="test"' + '|DstK8S_Type="Pod"&DstK8S_Namespace="ns"&DstK8S_Name="test"' @@ -233,10 +218,7 @@ describe('Match any, flows', () => { }); it('should generate Node K8S resource, back and forth', () => { - const grouped = getDecodedFilter( - { list: [filter('src_resource', [{ v: 'Node.test' }])], backAndForth: true }, - true - ); + const grouped = getDecodedFilter({ list: [filter('src_resource', [{ v: 'Node.test' }])], match: 'peers' }, true); expect(grouped).toEqual( 'SrcK8S_Type="Node"&SrcK8S_Namespace=""&SrcK8S_Name="test"' + '|DstK8S_Type="Node"&DstK8S_Namespace=""&DstK8S_Name="test"' @@ -245,7 +227,7 @@ describe('Match any, flows', () => { it('should generate Owner K8S resource, back and forth', () => { const grouped = getDecodedFilter( - { list: [filter('src_resource', [{ v: 'DaemonSet.ns.test' }])], backAndForth: true }, + { list: [filter('src_resource', [{ v: 'DaemonSet.ns.test' }])], match: 'peers' }, true ); expect(grouped).toEqual( @@ -258,7 +240,7 @@ describe('Match any, flows', () => { const grouped = getDecodedFilter( { list: [filter('src_resource', [{ v: 'Pod.ns.test' }]), filter('dst_name', [{ v: 'peer' }])], - backAndForth: true + match: 'peers' }, true ); @@ -290,7 +272,7 @@ describe('Match all, topology', () => { }); it('should encode', () => { - getTopoForFilter({ list: [filter('src_name', [{ v: 'test1' }, { v: 'test2' }])], backAndForth: false }, false); + getTopoForFilter({ list: [filter('src_name', [{ v: 'test1' }, { v: 'test2' }])], match: 'all' }, false); expect(getFlowMetricsMock).toHaveBeenCalledTimes(1); expect(getFlowMetricsMock.mock.calls[0][0].filters).toEqual('SrcK8S_Name%3Dtest1%2Ctest2'); }); @@ -299,7 +281,7 @@ describe('Match all, topology', () => { getTopoForFilter( { list: [filter('src_name', [{ v: 'test1' }, { v: 'test2' }]), filter('src_namespace', [{ v: 'ns' }])], - backAndForth: false + match: 'all' }, false ); @@ -313,7 +295,7 @@ describe('Match all, topology', () => { getTopoForFilter( { list: [filter('src_name', [{ v: 'test1' }, { v: 'test2' }]), filter('dst_port', [{ v: '443' }])], - backAndForth: true + match: 'peers' }, false ); diff --git a/web/src/utils/__tests__/router.spec.ts b/web/src/utils/__tests__/router.spec.ts index c79a18fa0..c5a2c2ad8 100644 --- a/web/src/utils/__tests__/router.spec.ts +++ b/web/src/utils/__tests__/router.spec.ts @@ -1,6 +1,6 @@ import { setNavFunction } from '../../components/dynamic-loader/dynamic-loader'; import { FilterDefinitionSample } from '../../components/__tests-data__/filters'; -import { Filters } from '../../model/filters'; +import { FilterCompare, Filters } from '../../model/filters'; import { findFilter } from '../filter-definitions'; import { getFiltersFromURL, setURLFilters } from '../router'; @@ -10,22 +10,23 @@ setNavFunction(nav); describe('Filters URL', () => { it('should set Filters -> URL', async () => { const filters: Filters = { - backAndForth: true, + match: 'peers', list: [ { def: findFilter(FilterDefinitionSample, 'src_namespace')!, + compare: FilterCompare.equal, values: [{ v: 'test' }] }, { def: findFilter(FilterDefinitionSample, 'dst_name')!, - values: [{ v: 'test' }], - not: true + compare: FilterCompare.notEqual, + values: [{ v: 'test' }] } ] }; setURLFilters(filters, false); - expect(nav).toHaveBeenCalledWith('/?filters=src_namespace%3Dtest%3Bdst_name%21%3Dtest&bnf=true', { + expect(nav).toHaveBeenCalledWith('/?filters=src_namespace%3Dtest%3Bdst_name%21%3Dtest&match=peers', { replace: false }); }); @@ -33,7 +34,7 @@ describe('Filters URL', () => { it('should get URL -> Filters', async () => { const location = { ...window.location, - search: '?filters=src_namespace%3Dtest%3Bdst_name%21%3Dtest&bnf=true' + search: '?filters=src_namespace%3Dtest%3Bdst_name%21%3Dtest&match=peers' }; Object.defineProperty(window, 'location', { writable: true, @@ -43,7 +44,7 @@ describe('Filters URL', () => { const prom = getFiltersFromURL(FilterDefinitionSample, {}); expect(prom).toBeDefined(); return prom!.then(filters => { - expect(filters.backAndForth).toBe(true); + expect(filters.match).toBe('peers'); expect(filters.list).toHaveLength(2); }); }); diff --git a/web/src/utils/back-and-forth.ts b/web/src/utils/back-and-forth.ts index 96c862c27..d9a9416e0 100644 --- a/web/src/utils/back-and-forth.ts +++ b/web/src/utils/back-and-forth.ts @@ -3,13 +3,31 @@ import { getFlowMetrics, getFlowRecords } from '../api/routes'; import { Filter, FilterDefinition, Filters } from '../model/filters'; import { filtersToString, FlowQuery } from '../model/flow-query'; import { computeStepInterval, TimeRange } from './datetime'; -import { swapFilters } from './filters-helper'; +import { setTargeteableFilters, swapFilters } from './filters-helper'; import { mergeStats, substractMetrics, sumMetrics } from './metrics'; export const getFetchFunctions = (filterDefinitions: FilterDefinition[], filters: Filters, matchAny: boolean) => { // check back-and-forth - if (filters.backAndForth) { - const swapped = swap(filterDefinitions, filters.list, matchAny); + if (filters.list.some(f => f.def.category === 'targeteable')) { + // set targetable filters as source filters + const srcList = setTargeteableFilters(filterDefinitions, filters.list, 'src'); + // set targetable filters as dest filters + const dstList = setTargeteableFilters(filterDefinitions, filters.list, 'dst'); + + return { + getRecords: (q: FlowQuery) => { + return getFlowsBNF(q, srcList, dstList, matchAny); + }, + getMetrics: (q: FlowQuery, range: number | TimeRange) => { + return getMetricsBNF(q, range, srcList, dstList, matchAny); + } + }; + } else if (filters.match === 'peers') { + let swapped = swapFilters(filterDefinitions, filters.list); + if (matchAny) { + // In match-any mode, remove non-swappable filters as they would result in duplicates + swapped = swapped.filter(f => f.def.id.startsWith('src_') || f.def.id.startsWith('dst_')); + } if (swapped.length > 0) { return { getRecords: (q: FlowQuery) => { @@ -51,7 +69,7 @@ const getMetricsBNF = ( // OVERLAP being ORIGINAL AND SWAPPED. // E.g: if ORIGINAL is "SrcNs=foo", SWAPPED is "DstNs=foo" and OVERLAP is "SrcNs=foo AND DstNs=foo" const overlapFilters = matchAny ? undefined : [...orig, ...swapped]; - const promOrig = getFlowMetrics(initialQuery, range); + const promOrig = getFlowMetrics({ ...initialQuery, filters: filtersToString(orig, matchAny) }, range); const promSwapped = getFlowMetrics({ ...initialQuery, filters: filtersToString(swapped, matchAny) }, range); const promOverlap = overlapFilters ? getFlowMetrics( @@ -87,13 +105,3 @@ export const mergeMetricsBNF = ( } return { metrics, stats }; }; - -const swap = (filterDefinitions: FilterDefinition[], filters: Filter[], matchAny: boolean): Filter[] => { - // include swapped traffic - const swapped = swapFilters(filterDefinitions, filters); - if (matchAny) { - // In match-any mode, remove non-swappable filters as they would result in duplicates - return swapped.filter(f => f.def.id.startsWith('src_') || f.def.id.startsWith('dst_')); - } - return swapped; -}; diff --git a/web/src/utils/filter-definitions.ts b/web/src/utils/filter-definitions.ts index f26ef6074..ba9310fc1 100644 --- a/web/src/utils/filter-definitions.ts +++ b/web/src/utils/filter-definitions.ts @@ -4,6 +4,7 @@ import { Field } from '../api/ipfix'; import { Config } from '../model/config'; import { FilterCategory, + FilterCompare, FilterComponent, FilterConfigDef, FilterDefinition, @@ -44,23 +45,21 @@ export const undefinedValue = '""'; // Unique double are allowed while typing but invalid export const doubleQuoteValue = '"'; -const matcher = (left: string, right: string[], not: boolean, moreThan: boolean) => - `${left}${not ? '!=' : moreThan ? '>=' : '='}${right.join(',')}`; +export const matcher = (left: string, right: string[], compare: FilterCompare) => `${left}${compare}${right.join(',')}`; const simpleFiltersEncoder = (field: Field): FiltersEncoder => { - return (values: FilterValue[], matchAny: boolean, not: boolean, moreThan: boolean) => { + return (values: FilterValue[], compare: FilterCompare) => { return matcher( field, values.map(v => v.v), - not, - moreThan || false + compare ); }; }; // As owner / non-owner kind filters are mixed, they are disambiguated via this function const kindFiltersEncoder = (base: Field, owner: Field): FiltersEncoder => { - return (values: FilterValue[], matchAny: boolean, not: boolean, moreThan: boolean) => { + return (values: FilterValue[], compare: FilterCompare, matchAny: boolean) => { const { baseValues, ownerValues } = _.groupBy(values, value => { return isOwnerKind(value.v) ? 'ownerValues' : 'baseValues'; }); @@ -70,8 +69,7 @@ const kindFiltersEncoder = (base: Field, owner: Field): FiltersEncoder => { matcher( base, baseValues.map(value => value.v), - not, - moreThan || false + compare ) ); } @@ -80,8 +78,7 @@ const kindFiltersEncoder = (base: Field, owner: Field): FiltersEncoder => { matcher( owner, ownerValues.map(value => value.v), - not, - moreThan || false + compare ) ); } @@ -96,14 +93,14 @@ const k8sResourceFiltersEncoder = ( name: Field, ownerName: Field ): FiltersEncoder => { - return (values: FilterValue[], matchAny: boolean, not: boolean) => { + return (values: FilterValue[], compare: FilterCompare, matchAny: boolean) => { const splitValues = values.map(value => splitResource(value.v)); return splitValues .map(res => { if (isOwnerKind(res.kind)) { - return k8sSingleResourceEncode(ownerKind, namespace, ownerName, res, not); + return k8sSingleResourceEncode(ownerKind, namespace, ownerName, res, compare.includes('!')); } else { - return k8sSingleResourceEncode(kind, namespace, name, res, not); + return k8sSingleResourceEncode(kind, namespace, name, res, compare.includes('!')); } }) .join(matchAny ? '|' : '&'); @@ -344,10 +341,10 @@ export const checkFilterAvailable = (fd: FilterDefinition, config: Config, dataS if (isPromOnly) { // "encode" a dummy query to check related labels, and make sure they're all part of available prom labels - const q = fd.encoder([{ v: 'any' }], false, false, false); + const q = fd.encoder([{ v: 'any' }], FilterCompare.match, false); const parts = q.split('&'); for (let i = 0; i < parts.length; i++) { - const kv = parts[i].split('='); + const kv = parts[i].split(/=|~/); if (kv.length === 0 || !config.promLabels.includes(kv[0])) { return false; } diff --git a/web/src/utils/filters-helper.ts b/web/src/utils/filters-helper.ts index 336618590..27e723649 100644 --- a/web/src/utils/filters-helper.ts +++ b/web/src/utils/filters-helper.ts @@ -1,5 +1,5 @@ import { TFunction } from 'i18next'; -import { Filter, FilterDefinition, FilterId } from '../model/filters'; +import { Filter, FilterDefinition, FilterId, FilterValue } from '../model/filters'; import { findFilter } from './filter-definitions'; export type Indicator = 'default' | 'success' | 'warning' | 'error' | undefined; @@ -9,52 +9,128 @@ export type FilterGroup = { filters: FilterDefinition[]; }; -export const buildGroups = (filterDefinitions: FilterDefinition[], t: TFunction): FilterGroup[] => { - return [ - { - title: t('Source'), - filters: filterDefinitions.filter(def => def.category === 'source') - }, - { - title: t('Destination'), - filters: filterDefinitions.filter(def => def.category === 'destination') - }, - { - title: t('Common'), - filters: filterDefinitions.filter(def => !def.category) - } - ].filter(g => g.filters.length); -}; - export const getFilterFullName = (f: FilterDefinition, t: TFunction) => { switch (f.category) { case 'source': - return `${t('Source')} ${f.name}`; + return `${t('From')} ${f.name}`; case 'destination': - return `${t('Destination')} ${f.name}`; + return `${t('To')} ${f.name}`; default: return f.name; } }; -export const hasSrcDstFilters = (filters: Filter[]): boolean => { +export const hasSrcOrDstFilters = (filters: Filter[]): boolean => { return filters.some(f => f.def.id.startsWith('src_') || f.def.id.startsWith('dst_')); }; +export const hasSrcAndDstFilters = (filters: Filter[]): boolean => { + return filters.some(f => f.def.id.startsWith('src_')) && filters.some(f => f.def.id.startsWith('dst_')); +}; + +export const swapFilterDefinition = ( + filterDefinitions: FilterDefinition[], + def: FilterDefinition, + target?: 'src' | 'dst' +): FilterDefinition => { + let swappedId: FilterId | undefined; + if (def.id.startsWith('src_')) { + swappedId = def.id.replace('src_', target ? `${target}_` : 'dst_') as FilterId; + } else if (def.id.startsWith('dst_')) { + swappedId = def.id.replace('dst_', target ? `${target}_` : 'src_') as FilterId; + } else if (def.category === 'targeteable' && target) { + swappedId = `${target}_${def.id}` as FilterId; + } + if (swappedId) { + return filterDefinitions.find(def => def.id === swappedId) || def; + } + return def; +}; + +export const swapFilter = (filterDefinitions: FilterDefinition[], filter: Filter, target?: 'src' | 'dst'): Filter => { + const def = swapFilterDefinition(filterDefinitions, filter.def, target); + if (def) { + return { ...filter, def }; + } + return filter; +}; + export const swapFilters = (filterDefinitions: FilterDefinition[], filters: Filter[]): Filter[] => { - return filters.map(f => { - let swappedId: FilterId | undefined; - if (f.def.id.startsWith('src_')) { - swappedId = f.def.id.replace('src_', 'dst_') as FilterId; - } else if (f.def.id.startsWith('dst_')) { - swappedId = f.def.id.replace('dst_', 'src_') as FilterId; - } - if (swappedId) { - const def = findFilter(filterDefinitions, swappedId); - if (def) { - return { ...f, def }; - } - } - return f; - }); + return filters.map(f => swapFilter(filterDefinitions, f)); +}; + +export const setTargeteableFilters = ( + filterDefinitions: FilterDefinition[], + filters: Filter[], + target: 'src' | 'dst' +): Filter[] => { + return filters.map(f => (f.def.category === 'targeteable' ? swapFilter(filterDefinitions, f, target) : f)); +}; + +export const swapFilterValue = ( + filterDefinitions: FilterDefinition[], + filters: Filter[], + id: FilterId, + value: FilterValue, + target: 'src' | 'dst' +): Filter[] => { + // remove value from existing filter + const found = filters.find(f => f.def.id === id); + if (!found) { + console.error("Can't find filter id", id); + return filters; + } + found.values = found.values.filter(val => val.v !== value.v); + + // remove filter if no more values + if (!found.values.length) { + filters = filters.filter(f => f !== found); + } + + // add new swapped filter + const swapped = swapFilter(filterDefinitions, { ...found, values: [value] }, target); + const existing = filters.find(f => f.def.id === swapped.def.id); + if (existing) { + existing.values.push(swapped.values[0]); + } else { + filters.push(swapped); + } + + return filters; +}; + +export const bnfFilterValue = ( + filterDefinitions: FilterDefinition[], + filters: Filter[], + id: FilterId, + value: FilterValue +): Filter[] => { + // remove value from existing filter + const found = filters.find(f => f.def.id === id); + if (!found) { + console.error("Can't find filter id", id); + return filters; + } + found.values = found.values.filter(val => val.v !== value.v); + + // remove filter if no more values + if (!found.values.length) { + filters = filters.filter(f => f !== found); + } + + // add new back and forth filter value + const bnfId = id.replace('src_', '').replace('dst_', '') as FilterId; + const def = findFilter(filterDefinitions, bnfId); + if (!def) { + console.error("Can't find filter def", bnfId); + return filters; + } + const existing = filters.find(f => f.def.id === bnfId); + if (!existing) { + filters.push({ ...found, def, values: [value] }); + } else if (existing.values.includes(value)) { + existing.values.push(value); + } + + return filters; }; diff --git a/web/src/utils/router.ts b/web/src/utils/router.ts index b87eecc20..109a22ec4 100644 --- a/web/src/utils/router.ts +++ b/web/src/utils/router.ts @@ -2,8 +2,8 @@ import { createFilterValue, DisabledFilters, Filter, + FilterCompare, FilterDefinition, - filterKey, Filters, fromFilterKey } from '../model/filters'; @@ -21,7 +21,6 @@ import { } from './url'; const filtersSeparator = ';'; -const filterKVSeparator = '='; const filterValuesSeparator = ','; export const defaultTimeRange = 300; export const defaultRecordType: RecordType = 'flowLog'; @@ -85,9 +84,9 @@ export const getFiltersFromURL = ( const filterPromises: Promise[] = []; const filters = urlParam.split(filtersSeparator); filters.forEach(keyValue => { - const pair = keyValue.split(filterKVSeparator); + const pair = keyValue.split(/=|~/); if (pair.length === 2) { - const { id, not, moreThan } = fromFilterKey(pair[0]); + const { id } = fromFilterKey(pair[0]); const def = findFilter(filterDefinitions, id); if (def) { const disabledValues = disabledFilters[pair[0]]?.split(',') || []; @@ -101,8 +100,15 @@ export const getFiltersFromURL = ( }); const f: Filter = { def: def, - not: not, - moreThan: moreThan, + compare: keyValue.includes(FilterCompare.moreThanOrEqual) + ? FilterCompare.moreThanOrEqual + : keyValue.includes(FilterCompare.notEqual) + ? FilterCompare.notEqual + : keyValue.includes(FilterCompare.equal) + ? FilterCompare.equal + : keyValue.includes(FilterCompare.notMatch) + ? FilterCompare.notMatch + : FilterCompare.match, values: filterValues }; return f; @@ -111,20 +117,20 @@ export const getFiltersFromURL = ( } } }); - const backAndForth = getURLParamAsBool(URLParam.BackAndForth) || false; - return Promise.all(filterPromises).then(list => ({ backAndForth, list })); + const match = (getURLParam(URLParam.Match) as Match) || defaultMatch; + return Promise.all(filterPromises).then(list => ({ match, list })); }; export const setURLFilters = (filters: Filters, replace?: boolean) => { const urlFilters = filters.list .map(filter => { - return filterKey(filter) + filterKVSeparator + filter.values.map(v => v.v).join(filterValuesSeparator); + return filter.def.id + filter.compare + filter.values.map(v => v.v).join(filterValuesSeparator); }) .join(filtersSeparator); setSomeURLParams( new Map([ [URLParam.Filters, urlFilters], - [URLParam.BackAndForth, filters.backAndForth ? 'true' : 'false'] + [URLParam.Match, filters.match] ]), replace ); diff --git a/web/src/utils/url.ts b/web/src/utils/url.ts index e049f0c33..a53caa571 100644 --- a/web/src/utils/url.ts +++ b/web/src/utils/url.ts @@ -18,8 +18,7 @@ export enum URLParam { DataSource = 'dataSource', ShowDuplicates = 'showDup', MetricFunction = 'function', - MetricType = 'type', - BackAndForth = 'bnf' + MetricType = 'type' } export type URLParams = { [k in URLParam]?: unknown };