From dddbf046d5d46c53bd1e537f7a43ef2f18ef40a0 Mon Sep 17 00:00:00 2001 From: Julien Pinsonneau Date: Tue, 3 Jun 2025 16:36:59 +0200 Subject: [PATCH 01/17] chips refactoring for more options --- web/locales/en/plugin__netobserv-plugin.json | 3 +- .../components/toolbar/filters-toolbar.css | 12 +- .../toolbar/filters/filters-chips.tsx | 118 ++++++++++++++---- web/src/utils/filters-helper.ts | 65 +++++++--- 4 files changed, 157 insertions(+), 41 deletions(-) diff --git a/web/locales/en/plugin__netobserv-plugin.json b/web/locales/en/plugin__netobserv-plugin.json index 6b688be50..4309d5089 100644 --- a/web/locales/en/plugin__netobserv-plugin.json +++ b/web/locales/en/plugin__netobserv-plugin.json @@ -373,10 +373,11 @@ "Enable": "Enable", "group filter": "group filter", "filter": "filter", + "Swap": "Swap", + "Remove": "Remove", "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", diff --git a/web/src/components/toolbar/filters-toolbar.css b/web/src/components/toolbar/filters-toolbar.css index 8245d22a5..331190d53 100644 --- a/web/src/components/toolbar/filters-toolbar.css +++ b/web/src/components/toolbar/filters-toolbar.css @@ -81,13 +81,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; } @@ -158,7 +164,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, diff --git a/web/src/components/toolbar/filters/filters-chips.tsx b/web/src/components/toolbar/filters/filters-chips.tsx index b0a994cfc..791e38f7d 100644 --- a/web/src/components/toolbar/filters/filters-chips.tsx +++ b/web/src/components/toolbar/filters/filters-chips.tsx @@ -1,5 +1,26 @@ -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, + MenuToggle, + MenuToggleElement, + Text, + TextContent, + TextVariants, + ToolbarGroup, + ToolbarItem, + Tooltip +} from '@patternfly/react-core'; +import { + ArrowsAltVIcon, + BanIcon, + CheckIcon, + LongArrowAltDownIcon, + LongArrowAltUpIcon, + TimesCircleIcon, + TimesIcon +} from '@patternfly/react-icons'; import * as _ from 'lodash'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; @@ -13,7 +34,7 @@ import { } from '../../../model/filters'; import { QuickFilter } from '../../../model/quick-filters'; import { autoCompleteCache } from '../../../utils/autocomplete-cache'; -import { getFilterFullName, hasSrcDstFilters, swapFilters } from '../../../utils/filters-helper'; +import { getFilterFullName, hasSrcDstFilters, swapFilters, swapFilterValue } from '../../../utils/filters-helper'; import { getPathWithParams, netflowTrafficPath } from '../../../utils/url'; import { navigate } from '../../dynamic-loader/dynamic-loader'; import { LinksOverflow } from '../links-overflow'; @@ -40,6 +61,8 @@ export const FiltersChips: React.FC = ({ }) => { const { t } = useTranslation('plugin__netobserv-plugin'); + const [openedDropdown, setOpenedDropdown] = React.useState(); + const setFiltersList = React.useCallback( (list: Filter[]) => { setFilters({ ...filters, list: list }); @@ -101,27 +124,78 @@ export const FiltersChips: React.FC = ({ {chipFilter.values.map((chipFilterValue, fvIndex) => { + if (isForced || chipFilterValue.disabled) { + return ( +
+ + { + chipFilterValue.disabled = !chipFilterValue.disabled; + setFilters(_.cloneDeep(filters)); + }} + > + {chipFilterValue.display ? chipFilterValue.display : chipFilterValue.v} + + +
+ ); + } + + const dropdownId = `${chipFilter.def.id}-${fvIndex}`; return ( -
- - setOpenedDropdown(isOpen ? dropdownId : undefined)} + toggle={(toggleRef: React.Ref) => ( + setOpenedDropdown(openedDropdown === dropdownId ? undefined : dropdownId)} + > + {chipFilterValue.display ? chipFilterValue.display : chipFilterValue.v} + + )} + > + + { - //switch value chipFilterValue.disabled = !chipFilterValue.disabled; setFilters(_.cloneDeep(filters)); + setOpenedDropdown(undefined); }} > - {chipFilterValue.display ? chipFilterValue.display : chipFilterValue.v} - - - {!isForced && ( - - )} -
+  {t('Remove')} + + + ); })} {!isForced && ( diff --git a/web/src/utils/filters-helper.ts b/web/src/utils/filters-helper.ts index 336618590..f3066b97a 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; @@ -41,20 +41,53 @@ export const hasSrcDstFilters = (filters: Filter[]): boolean => { return filters.some(f => f.def.id.startsWith('src_') || f.def.id.startsWith('dst_')); }; -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 }; - } +export const swapFilter = (filterDefinitions: FilterDefinition[], filter: Filter): Filter => { + let swappedId: FilterId | undefined; + if (filter.def.id.startsWith('src_')) { + swappedId = filter.def.id.replace('src_', 'dst_') as FilterId; + } else if (filter.def.id.startsWith('dst_')) { + swappedId = filter.def.id.replace('dst_', 'src_') as FilterId; + } + if (swappedId) { + const def = findFilter(filterDefinitions, swappedId); + if (def) { + return { ...filter, def }; } - return f; - }); + } + return filter; +}; + +export const swapFilters = (filterDefinitions: FilterDefinition[], filters: Filter[]): Filter[] => { + return filters.map(f => swapFilter(filterDefinitions, f)); +}; + +export const swapFilterValue = ( + 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 swapped filter + const swapped = swapFilter(filterDefinitions, { ...found, values: [value] }); + 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; }; From 3ebf1a057b2ca9f231eb0ec4289a3275ab91de5a Mon Sep 17 00:00:00 2001 From: Julien Pinsonneau Date: Thu, 12 Jun 2025 16:32:14 +0200 Subject: [PATCH 02/17] refactor match and back and forth options --- config/sample-config.yaml | 143 +++++++ web/locales/en/plugin__netobserv-plugin.json | 24 +- web/src/components/__tests-data__/filters.ts | 95 +++++ .../drawer/netflow-traffic-drawer.tsx | 10 +- .../__tests__/query-options-dropdown.spec.tsx | 20 +- .../components/dropdowns/match-dropdown.tsx | 87 ++++ .../dropdowns/query-options-dropdown.tsx | 4 +- .../dropdowns/query-options-panel.tsx | 46 +- web/src/components/netflow-traffic-tab.tsx | 14 +- web/src/components/netflow-traffic.tsx | 17 +- .../netflow-topology/2d/topology-content.tsx | 2 +- .../__tests__/netflow-topology.spec.tsx | 3 +- .../components/toolbar/filters-toolbar.css | 19 + .../components/toolbar/filters-toolbar.tsx | 19 +- .../filters/__tests__/filters-chips.spec.tsx | 2 +- .../__tests__/filters-toolbar.spec.tsx | 21 +- .../toolbar/filters/filters-chips.css | 10 +- .../toolbar/filters/filters-chips.tsx | 398 ++++++++++-------- .../toolbar/filters/filters-dropdown.tsx | 65 +-- web/src/model/filters.ts | 8 +- web/src/model/flow-query.ts | 2 +- web/src/model/netflow-traffic.ts | 17 +- .../utils/__tests__/back-and-forth.spec.ts | 62 +-- web/src/utils/__tests__/router.spec.ts | 8 +- web/src/utils/back-and-forth.ts | 36 +- web/src/utils/filters-helper.ts | 105 +++-- web/src/utils/router.ts | 6 +- web/src/utils/url.ts | 3 +- 28 files changed, 800 insertions(+), 446 deletions(-) create mode 100644 web/src/components/dropdowns/match-dropdown.tsx 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/web/locales/en/plugin__netobserv-plugin.json b/web/locales/en/plugin__netobserv-plugin.json index 4309d5089..d59e7f224 100644 --- a/web/locales/en/plugin__netobserv-plugin.json +++ b/web/locales/en/plugin__netobserv-plugin.json @@ -70,6 +70,9 @@ "Force": "Force", "Grid": "Grid", "Invalid": "Invalid", + "Any": "Any", + "One way": "One way", + "Peers": "Peers", "rate": "rate", "Average": "Average", "Latest": "Latest", @@ -95,8 +98,6 @@ "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", @@ -109,8 +110,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", @@ -367,23 +366,25 @@ "Not equals": "Not equals", "More than": "More than", "Learn more": "Learn more", + "Peer A": "Peer A", + "Peer B": "Peer B", + "Peer": "Peer", "Not": "Not", "more than": "more than", "Disable": "Disable", "Enable": "Enable", "group filter": "group filter", "filter": "filter", - "Swap": "Swap", + "As peer A": "As peer A", + "As source": "As source", + "As peer B": "As peer B", + "As destination": "As destination", "Remove": "Remove", "Edit filters": "Edit filters", "Reset defaults": "Reset defaults", "Clear all": "Clear all", - "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": "Swap", + "Swap from and to filters": "Swap from and to filters", "Quick filters": "Quick filters", "More options": "More options", "Export overview": "Export overview", @@ -419,7 +420,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..ea6548e21 100644 --- a/web/src/components/__tests-data__/filters.ts +++ b/web/src/components/__tests-data__/filters.ts @@ -4,6 +4,16 @@ 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,6 +34,15 @@ 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', @@ -42,6 +61,13 @@ 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: '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', diff --git a/web/src/components/drawer/netflow-traffic-drawer.tsx b/web/src/components/drawer/netflow-traffic-drawer.tsx index 3d43d50d8..0732cd051 100644 --- a/web/src/components/drawer/netflow-traffic-drawer.tsx +++ b/web/src/components/drawer/netflow-traffic-drawer.tsx @@ -16,7 +16,7 @@ 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 { Warning } from '../../model/warnings'; import { Column, ColumnSizeMap } from '../../utils/columns'; @@ -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 = () => { 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..4154aa19f --- /dev/null +++ b/web/src/components/dropdowns/match-dropdown.tsx @@ -0,0 +1,87 @@ +import { Dropdown, DropdownItem, MenuToggle, MenuToggleElement } from '@patternfly/react-core'; +import { LongArrowAltDownIcon, LongArrowAltUpIcon, 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 = ['any', 'all', '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('One way'); + 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); + }} + > + {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 values: [{ v: `${obj.kind}.${obj.metadata!.namespace}.${obj.metadata!.name}` }] } ], - backAndForth: true + match: 'peers' }); break; case 'Service': @@ -129,7 +129,7 @@ export const NetflowTrafficTab: React.FC = ({ match, obj values: [{ v: `${obj.kind}.${obj.metadata!.namespace}.${obj.metadata!.name}` }] } ], - backAndForth: false + match: 'all' }); break; case 'Route': @@ -141,7 +141,7 @@ export const NetflowTrafficTab: React.FC = ({ match, obj values: [{ v: `${route.spec.to!.kind}.${route.metadata!.namespace}.${route.spec.to!.name}` }] } ], - backAndForth: false + match: 'all' }); break; case 'Namespace': @@ -152,7 +152,7 @@ export const NetflowTrafficTab: React.FC = ({ match, obj values: [{ v: obj!.metadata!.name as string }] } ], - backAndForth: true + match: 'peers' }); break; case 'Node': @@ -163,7 +163,7 @@ export const NetflowTrafficTab: React.FC = ({ match, obj values: [{ v: obj!.metadata!.name as string }] } ], - backAndForth: true + match: 'peers' }); break; case 'ReplicaSet': @@ -176,7 +176,7 @@ export const NetflowTrafficTab: React.FC = ({ match, obj }) } ], - backAndForth: true + match: 'peers' }); break; case 'HorizontalPodAutoscaler': @@ -190,7 +190,7 @@ export const NetflowTrafficTab: React.FC = ({ match, obj ] } ], - 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/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 331190d53..c2a9aa1f0 100644 --- a/web/src/components/toolbar/filters-toolbar.css +++ b/web/src/components/toolbar/filters-toolbar.css @@ -60,6 +60,7 @@ div#filter-toolbar-search-filters { margin-right: 0; } +.custom-chip-peer, .custom-chip-group, .custom-chip { border-radius: 3px; @@ -68,6 +69,17 @@ div#filter-toolbar-search-filters { align-items: center; } +.custom-chip-box { + padding: 0.2em 0.5em 0.2em 0.5em; + margin: 0 1em 0 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; @@ -117,6 +129,11 @@ div#filter-toolbar-search-filters { background-color: #6A6E73; } +.pf-v5-theme-dark .custom-chip-box { + 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>* { @@ -180,6 +197,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..b1f8df290 100644 --- a/web/src/components/toolbar/filters-toolbar.tsx +++ b/web/src/components/toolbar/filters-toolbar.tsx @@ -15,7 +15,7 @@ import { Filter, FilterDefinition, Filters, FilterValue, findFromFilters } from import { QuickFilter } from '../../model/quick-filters'; import { autoCompleteCache } from '../../utils/autocomplete-cache'; import { findFilter } from '../../utils/filter-definitions'; -import { Indicator } from '../../utils/filters-helper'; +import { Indicator, swapFilterDefinition } from '../../utils/filters-helper'; import { localStorageShowFiltersKey, useLocalStorage } from '../../utils/local-storage-hook'; import { QueryOptionsDropdown, QueryOptionsProps } from '../dropdowns/query-options-dropdown'; import './filters-toolbar.css'; @@ -94,10 +94,12 @@ export const FiltersToolbar: React.FC = ({ console.error('addFilter called with', selectedFilter); return false; } + const def = + filters?.match !== 'any' ? swapFilterDefinition(filterDefinitions, selectedFilter, 'src') : selectedFilter; const newFilters = _.cloneDeep(filters?.list) || []; const not = selectedCompare === FilterCompare.notEqual; const moreThan = selectedCompare === FilterCompare.moreThanOrEqual; - const found = findFromFilters(newFilters, { def: selectedFilter, not, moreThan }); + const found = findFromFilters(newFilters, { def, not, moreThan }); if (found) { if (found.values.map(value => value.v).includes(filterValue.v)) { setMessageWithDelay(t('Filter already exists')); @@ -107,12 +109,21 @@ export const FiltersToolbar: React.FC = ({ found.values.push(filterValue); } } else { - newFilters.push({ def: selectedFilter, not, moreThan, values: [filterValue] }); + newFilters.push({ def, not, moreThan, values: [filterValue] }); } setFiltersList(newFilters); return true; }, - [filters, selectedCompare, selectedFilter, setFiltersList, setMessageWithDelay, t] + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + filterDefinitions, + filters?.list, + filters?.match, + selectedCompare, + selectedFilter, + setFiltersList, + setMessageWithDelay + ] ); const getFilterControl = React.useCallback(() => { 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..e90735096 100644 --- a/web/src/components/toolbar/filters/__tests__/filters-chips.spec.tsx +++ b/web/src/components/toolbar/filters/__tests__/filters-chips.spec.tsx @@ -5,7 +5,7 @@ import { FiltersChips, FiltersChipsProps } from '../filters-chips'; describe('', () => { const props: FiltersChipsProps = { - filters: { backAndForth: false, list: [] }, + filters: { match: 'all', list: [] }, setFilters: jest.fn(), clearFilters: jest.fn(), resetFilters: jest.fn(), 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..6017533cd 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() @@ -59,8 +57,7 @@ describe('', () => { //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); @@ -76,24 +73,24 @@ describe('', () => { //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/filters-chips.css b/web/src/components/toolbar/filters/filters-chips.css index dc37ef90a..85d3cec9e 100644 --- a/web/src/components/toolbar/filters/filters-chips.css +++ b/web/src/components/toolbar/filters/filters-chips.css @@ -1,6 +1,14 @@ .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__text { + padding: 0.25em 1em 0.25em 1em; +} + +.custom-chip>.pf-v5-c-menu-toggle__controls { + padding: 0.25em; } \ 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 791e38f7d..86faa8912 100644 --- a/web/src/components/toolbar/filters/filters-chips.tsx +++ b/web/src/components/toolbar/filters/filters-chips.tsx @@ -6,18 +6,17 @@ import { MenuToggle, MenuToggleElement, Text, - TextContent, TextVariants, ToolbarGroup, ToolbarItem, Tooltip } from '@patternfly/react-core'; import { + ArrowLeftIcon, + ArrowRightIcon, ArrowsAltVIcon, BanIcon, CheckIcon, - LongArrowAltDownIcon, - LongArrowAltUpIcon, TimesCircleIcon, TimesIcon } from '@patternfly/react-icons'; @@ -29,13 +28,24 @@ import { 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, swapFilterValue } from '../../../utils/filters-helper'; +import { + bnfFilterValue, + getFilterFullName, + 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'; @@ -50,6 +60,11 @@ export interface FiltersChipsProps { filterDefinitions: FilterDefinition[]; } +export interface FiltersGroup { + id: 'src' | 'dst' | 'common'; + filters: Filter[]; +} + export const FiltersChips: React.FC = ({ isForced, filters, @@ -63,6 +78,43 @@ export const FiltersChips: React.FC = ({ 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 }); @@ -70,23 +122,178 @@ 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 getFilterDisplay = React.useCallback( + (chipFilter: Filter, cfIndex: number) => { + let fullName = filters.match === 'any' ? getFilterFullName(chipFilter.def, t) : chipFilter.def.name; + 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) => { + if (isForced || chipFilterValue.disabled) { + return ( +
+ + { + chipFilterValue.disabled = !chipFilterValue.disabled; + setFilters(_.cloneDeep(filters)); + }} + > + {chipFilterValue.display ? chipFilterValue.display : chipFilterValue.v} + + +
+ ); + } - const chipFilters = filters.list; - if (_.isEmpty(chipFilters) && _.isEmpty(defaultFilters)) { + const dropdownId = `${chipFilter.def.id}-${fvIndex}`; + return ( + setOpenedDropdown(isOpen ? dropdownId : undefined)} + toggle={(toggleRef: React.Ref) => ( + setOpenedDropdown(openedDropdown === dropdownId ? undefined : dropdownId)} + > + {chipFilterValue.display ? chipFilterValue.display : chipFilterValue.v} + + )} + > + + { + chipFilterValue.disabled = !chipFilterValue.disabled; + setFilters(_.cloneDeep(filters)); + setOpenedDropdown(undefined); + }} + > + {chipFilterValue.disabled && } + {!chipFilterValue.disabled && } +  {chipFilterValue.disabled ? t('Enable') : t('Disable')} + + {filters.match !== 'peers' && + (chipFilter.def.id.startsWith('src_') || chipFilter.def.id.startsWith('dst_')) && ( + { + const bnf = bnfFilterValue( + filterDefinitions, + filters!.list, + chipFilter.def.id, + chipFilterValue + ); + setFilters({ ...filters!, list: bnf }); + setOpenedDropdown(undefined); + }} + > + +  {t('Any')} + + )} + {(chipFilter.def.category === 'targeteable' || chipFilter.def.id.startsWith('dst_')) && ( + swapValue(chipFilter, chipFilterValue, 'src')}> + +  {filters.match === 'peers' ? t('As peer A') : t('As source')} + + )} + {(chipFilter.def.category === 'targeteable' || chipFilter.def.id.startsWith('src_')) && ( + swapValue(chipFilter, chipFilterValue, 'dst')}> + +  {filters.match === 'peers' ? t('As peer B') : t('As destination')} + + )} + { + chipFilter.values = chipFilter.values.filter(val => val.v !== chipFilterValue.v); + if (_.isEmpty(chipFilter.values)) { + setFiltersList(removeFromFilters(filters.list, chipFilter)); + } else { + setFilters(_.cloneDeep(filters)); + } + setOpenedDropdown(undefined); + }} + > + +  {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(chipFilters, defaultFilters); - const isSrcDst = hasSrcDstFilters(chipFilters!); + const isDefaultFilters = filtersEqual(filters.list, getDefaultFilters()); return ( = ({ id={`${isForced ? 'forced-' : ''}filters`} variant="filter-group" > + - {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); + {getGroups() + .filter(gp => gp.filters.length) + .map(gp => { return ( -
- - { - //switch all values if no remaining - chipFilter.values.forEach(fv => { - fv.disabled = someEnabled; - }); - setFilters(_.cloneDeep(filters)); - }} - > - {fullName} - - - {chipFilter.values.map((chipFilterValue, fvIndex) => { - if (isForced || chipFilterValue.disabled) { - return ( -
- - { - chipFilterValue.disabled = !chipFilterValue.disabled; - setFilters(_.cloneDeep(filters)); - }} - > - {chipFilterValue.display ? chipFilterValue.display : chipFilterValue.v} - - -
- ); - } - - const dropdownId = `${chipFilter.def.id}-${fvIndex}`; - return ( - setOpenedDropdown(isOpen ? dropdownId : undefined)} - toggle={(toggleRef: React.Ref) => ( - setOpenedDropdown(openedDropdown === dropdownId ? undefined : dropdownId)} - > - {chipFilterValue.display ? chipFilterValue.display : chipFilterValue.v} - - )} - > - - { - chipFilterValue.disabled = !chipFilterValue.disabled; - setFilters(_.cloneDeep(filters)); - setOpenedDropdown(undefined); - }} - > - {chipFilterValue.disabled && } - {!chipFilterValue.disabled && } -  {chipFilterValue.disabled ? t('Enable') : t('Disable')} - - {(chipFilter.def.id.startsWith('src_') || chipFilter.def.id.startsWith('dst_')) && ( - { - const swapped = swapFilterValue( - filterDefinitions, - filters!.list, - chipFilter.def.id, - chipFilterValue - ); - setFilters({ ...filters!, list: swapped }); - setOpenedDropdown(undefined); - }} - > - -  {t('Swap')} - - )} - { - chipFilter.values = chipFilter.values.filter(val => val.v !== chipFilterValue.v); - if (_.isEmpty(chipFilter.values)) { - setFiltersList(removeFromFilters(filters.list, chipFilter)); - } else { - setFilters(_.cloneDeep(filters)); - } - setOpenedDropdown(undefined); - }} - > - -  {t('Remove')} - - - - ); - })} - {!isForced && ( - - )} +
+ {hasSrcOrDstFilters(filters.list) && {getGroupName(gp.id)} } +
{gp.filters.map(getFilterDisplay)}
); })} @@ -242,7 +335,7 @@ export const FiltersChips: React.FC = ({ resetFilters(); autoCompleteCache.clear(); }, - enabled: defaultFilters.length > 0 && !isDefaultFilters + enabled: getDefaultFilters().length > 0 && !isDefaultFilters }, { id: 'clear-all-filters', @@ -251,39 +344,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.tsx b/web/src/components/toolbar/filters/filters-dropdown.tsx index 080ece985..585468c2d 100644 --- a/web/src/components/toolbar/filters/filters-dropdown.tsx +++ b/web/src/components/toolbar/filters/filters-dropdown.tsx @@ -1,17 +1,8 @@ -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionToggle, - Dropdown, - DropdownItem, - MenuToggle, - MenuToggleElement -} from '@patternfly/react-core'; +import { Dropdown, DropdownItem, 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 { getFilterFullName } from '../../../utils/filters-helper'; import { useOutsideClickEvent } from '../../../utils/outside-hook'; import './filters-dropdown.css'; @@ -27,45 +18,27 @@ export const FiltersDropdown: React.FC = ({ 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 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 => !f.category || f.category === 'targeteable') + .map((f, index) => ( + { + setOpen(false); + setSelectedFilter(f); + }} + key={index} + > + {f.name} + + )); }; return ( diff --git a/web/src/model/filters.ts b/web/src/model/filters.ts index 88029af36..b70729a78 100644 --- a/web/src/model/filters.ts +++ b/web/src/model/filters.ts @@ -1,12 +1,13 @@ import _ from 'lodash'; 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 FilterComponent = 'autocomplete' | 'text' | 'number'; -export type FilterCategory = 'source' | 'destination'; +export type FilterCategory = 'source' | 'destination' | 'targeteable'; export type TargetedFilterId = | 'zone' @@ -23,6 +24,7 @@ export type TargetedFilterId = export type FilterId = | 'cluster_name' + | TargetedFilterId | `src_${TargetedFilterId}` | `dst_${TargetedFilterId}` | 'protocol' @@ -93,7 +95,7 @@ export interface Filter { export interface Filters { list: Filter[]; - backAndForth: boolean; + match: Match; } export interface FilterOption { @@ -136,7 +138,7 @@ export const getEnabledFilters = (filters: Filters): Filters => { return f; }) .filter(f => !_.isEmpty(f.values)), - backAndForth: filters.backAndForth + match: filters.match }; }; diff --git a/web/src/model/flow-query.ts b/web/src/model/flow-query.ts index 4bc14a411..8586391b5 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'; 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/utils/__tests__/back-and-forth.spec.ts b/web/src/utils/__tests__/back-and-forth.spec.ts index 5e7cac41e..6b0a0848e 100644 --- a/web/src/utils/__tests__/back-and-forth.spec.ts +++ b/web/src/utils/__tests__/back-and-forth.spec.ts @@ -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..bdf37a8d6 100644 --- a/web/src/utils/__tests__/router.spec.ts +++ b/web/src/utils/__tests__/router.spec.ts @@ -10,7 +10,7 @@ setNavFunction(nav); describe('Filters URL', () => { it('should set Filters -> URL', async () => { const filters: Filters = { - backAndForth: true, + match: 'peers', list: [ { def: findFilter(FilterDefinitionSample, 'src_namespace')!, @@ -25,7 +25,7 @@ describe('Filters URL', () => { }; 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 +33,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 +43,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/filters-helper.ts b/web/src/utils/filters-helper.ts index f3066b97a..27e723649 100644 --- a/web/src/utils/filters-helper.ts +++ b/web/src/utils/filters-helper.ts @@ -9,50 +9,48 @@ 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 swapFilter = (filterDefinitions: FilterDefinition[], filter: Filter): Filter => { +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 (filter.def.id.startsWith('src_')) { - swappedId = filter.def.id.replace('src_', 'dst_') as FilterId; - } else if (filter.def.id.startsWith('dst_')) { - swappedId = filter.def.id.replace('dst_', 'src_') as FilterId; + 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) { - const def = findFilter(filterDefinitions, swappedId); - if (def) { - return { ...filter, def }; - } + 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; }; @@ -61,11 +59,20 @@ export const swapFilters = (filterDefinitions: FilterDefinition[], filters: Filt 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 + value: FilterValue, + target: 'src' | 'dst' ): Filter[] => { // remove value from existing filter const found = filters.find(f => f.def.id === id); @@ -81,7 +88,7 @@ export const swapFilterValue = ( } // add new swapped filter - const swapped = swapFilter(filterDefinitions, { ...found, values: [value] }); + 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]); @@ -91,3 +98,39 @@ export const swapFilterValue = ( 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..e9bd424e2 100644 --- a/web/src/utils/router.ts +++ b/web/src/utils/router.ts @@ -111,8 +111,8 @@ 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) => { @@ -124,7 +124,7 @@ export const setURLFilters = (filters: Filters, replace?: boolean) => { 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 }; From 3805569b57fc3d5f661f9f6aac525010b0d85eae Mon Sep 17 00:00:00 2001 From: Julien Pinsonneau Date: Tue, 17 Jun 2025 13:04:07 +0200 Subject: [PATCH 03/17] search input with popper --- web/locales/en/plugin__netobserv-plugin.json | 6 +- .../components/toolbar/filters-toolbar.tsx | 132 +------- .../__tests__/autocomplete-filter.spec.tsx | 4 +- .../filters/__tests__/text-filter.spec.tsx | 4 +- .../toolbar/filters/autocomplete-filter.css | 8 +- .../toolbar/filters/autocomplete-filter.tsx | 87 +++--- .../toolbar/filters/filter-search-input.css | 3 + .../toolbar/filters/filter-search-input.tsx | 293 ++++++++++++++++++ .../toolbar/filters/filters-chips.tsx | 3 +- .../toolbar/filters/filters-dropdown.tsx | 144 +++++++-- .../toolbar/filters/text-filter.tsx | 56 ++-- web/src/utils/filter-definitions.ts | 2 +- 12 files changed, 523 insertions(+), 219 deletions(-) create mode 100644 web/src/components/toolbar/filters/filter-search-input.css create mode 100644 web/src/components/toolbar/filters/filter-search-input.tsx diff --git a/web/locales/en/plugin__netobserv-plugin.json b/web/locales/en/plugin__netobserv-plugin.json index d59e7f224..d4ec7a7f7 100644 --- a/web/locales/en/plugin__netobserv-plugin.json +++ b/web/locales/en/plugin__netobserv-plugin.json @@ -354,7 +354,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", @@ -366,6 +365,10 @@ "Not equals": "Not equals", "More than": "More than", "Learn more": "Learn more", + "Filter already exists": "Filter already exists", + "Filter": "Filter", + "Comparator": "Comparator", + "Add filter": "Add filter", "Peer A": "Peer A", "Peer B": "Peer B", "Peer": "Peer", @@ -385,6 +388,7 @@ "Clear all": "Clear all", "Swap": "Swap", "Swap from and to filters": "Swap from and to filters", + "Any direction": "Any direction", "Quick filters": "Quick filters", "More options": "More options", "Export overview": "Export overview", diff --git a/web/src/components/toolbar/filters-toolbar.tsx b/web/src/components/toolbar/filters-toolbar.tsx index b1f8df290..0ca46c618 100644 --- a/web/src/components/toolbar/filters-toolbar.tsx +++ b/web/src/components/toolbar/filters-toolbar.tsx @@ -1,31 +1,17 @@ -import { - Button, - InputGroup, - Toolbar, - ToolbarContent, - ToolbarItem, - Tooltip, - ValidatedOptions -} from '@patternfly/react-core'; +import { Button, Toolbar, ToolbarContent, ToolbarItem, Tooltip } 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 { FilterDefinition, Filters } from '../../model/filters'; import { QuickFilter } from '../../model/quick-filters'; import { autoCompleteCache } from '../../utils/autocomplete-cache'; -import { findFilter } from '../../utils/filter-definitions'; -import { Indicator, swapFilterDefinition } 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 { @@ -58,12 +44,8 @@ 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( - findFilter(filterDefinitions, 'src_namespace') || filterDefinitions.length ? filterDefinitions[0] : null - ); - const [selectedCompare, setSelectedCompare] = React.useState(FilterCompare.equal); + const [showFilters, setShowFilters] = useLocalStorage(localStorageShowFiltersKey, true); // reset and delay message state to trigger tooltip properly @@ -81,83 +63,7 @@ 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 def = - filters?.match !== 'any' ? swapFilterDefinition(filterDefinitions, selectedFilter, 'src') : selectedFilter; - const newFilters = _.cloneDeep(filters?.list) || []; - const not = selectedCompare === FilterCompare.notEqual; - const moreThan = selectedCompare === FilterCompare.moreThanOrEqual; - const found = findFromFilters(newFilters, { def, 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, not, moreThan, values: [filterValue] }); - } - setFiltersList(newFilters); - return true; - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - filterDefinitions, - filters?.list, - filters?.match, - selectedCompare, - selectedFilter, - setFiltersList, - setMessageWithDelay - ] - ); - - 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 getFilterToolbar = React.useCallback(() => { - if (selectedFilter === null) { - return <>; - } - return ( = ({ enableFlip={false} position={'top'} > -
- - - - {getFilterControl()} - - -
+
); - }, [filterDefinitions, getFilterControl, message, selectedCompare, selectedFilter]); + }, [filterDefinitions, filters, message, setFilters, setMessageWithDelay]); const isForced = !_.isEmpty(forcedFilters); const filtersOrForced = isForced ? forcedFilters : filters; @@ -210,7 +106,11 @@ export const FiltersToolbar: React.FC = ({ {!isForced && quickFilters.length > 0 && ( - + setFilters({ ...filters!, list })} + /> )} {!isForced && getFilterToolbar()} 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..aa8ee5ec4 100644 --- a/web/src/components/toolbar/filters/__tests__/autocomplete-filter.spec.tsx +++ b/web/src/components/toolbar/filters/__tests__/autocomplete-filter.spec.tsx @@ -10,8 +10,10 @@ 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(() => { 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..4a92292a2 100644 --- a/web/src/components/toolbar/filters/__tests__/text-filter.spec.tsx +++ b/web/src/components/toolbar/filters/__tests__/text-filter.spec.tsx @@ -10,8 +10,10 @@ 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(() => { diff --git a/web/src/components/toolbar/filters/autocomplete-filter.css b/web/src/components/toolbar/filters/autocomplete-filter.css index e885b52fa..f30c5fa62 100644 --- a/web/src/components/toolbar/filters/autocomplete-filter.css +++ b/web/src/components/toolbar/filters/autocomplete-filter.css @@ -1,4 +1,10 @@ #autocomplete-menu-button { - width: 30px; + width: 2.2em; + height: 2.2em; + padding: 0; +} + +#autocomplete-container { + margin: 0; padding: 0; } \ 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..e1ef80732 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,7 +10,7 @@ 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'; @@ -27,23 +29,26 @@ 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(() => { @@ -66,9 +71,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 +92,17 @@ export const AutocompleteFilter: React.FC = ({ setCurrentValue(newValue); filterDefinition .getOptions(newValue) - .then(setOptions) + .then(v => { + console.log('onAutoCompleteChange', 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 +150,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 +165,7 @@ export const AutocompleteFilter: React.FC = ({ filterDefinition, currentValue, onAutoCompleteOptionSelected, - setMessageWithDelay, + setMessage, setIndicator, addFilterParent, resetFilterValue @@ -175,8 +183,8 @@ 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/filter-search-input.css b/web/src/components/toolbar/filters/filter-search-input.css new file mode 100644 index 000000000..403d0a052 --- /dev/null +++ b/web/src/components/toolbar/filters/filter-search-input.css @@ -0,0 +1,3 @@ +#filter-search-input { + min-width: 500px !important; +} \ 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..151b7eff0 --- /dev/null +++ b/web/src/components/toolbar/filters/filter-search-input.tsx @@ -0,0 +1,293 @@ +import { + ActionGroup, + Button, + Form, + FormGroup, + Panel, + PanelMain, + PanelMainBody, + Popper, + SearchInput, + ValidatedOptions +} from '@patternfly/react-core'; +import _ from 'lodash'; +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { FilterDefinition, Filters, FilterValue, findFromFilters } from '../../../model/filters'; +import { findFilter, matcher } from '../../../utils/filter-definitions'; +import { Indicator, swapFilterDefinition } from '../../../utils/filters-helper'; +import { useOutsideClickEvent } from '../../../utils/outside-hook'; +import AutocompleteFilter from './autocomplete-filter'; +import CompareFilter, { FilterCompare } from './compare-filter'; +import { FilterHints } from './filter-hints'; +import './filter-search-input.css'; +import FiltersDropdown from './filters-dropdown'; +import TextFilter from './text-filter'; + +export interface FilterSearchInputProps { + filterDefinitions: FilterDefinition[]; + filters?: Filters; + setFilters: (v: Filters) => void; + setMessage: (m: string | undefined) => void; +} + +export const FilterSearchInput: React.FC = ({ + filterDefinitions, + filters, + setFilters, + setMessage +}) => { + const { t } = useTranslation('plugin__netobserv-plugin'); + + const [direction, setDirection] = React.useState<'source' | 'destination'>(); + const [filter, setFilter] = React.useState( + findFilter(filterDefinitions, 'src_namespace') || filterDefinitions.length ? filterDefinitions[0] : null + ); + const [compare, setCompare] = React.useState(FilterCompare.equal); + const [value, setValue] = React.useState(''); + const [indicator, setIndicator] = React.useState(ValidatedOptions.default); + + const searchInputRef = React.useRef(null); + const [searchInputValue, setSearchInputValue] = React.useState(''); + const advancedSearchPaneRef = useOutsideClickEvent(() => { + setSearchInputValue(getEncodedValue()); + setAdvancedSearchOpen(false); + // clear search field to show the placeholder back + if (_.isEmpty(value)) { + setSearchInputValue(''); + } + }); + const [isAdvancedSearchOpen, setAdvancedSearchOpen] = React.useState(false); + const [submitPending, setSubmitPending] = React.useState(false); + + const reset = React.useCallback(() => { + setCompare(FilterCompare.equal); + setValue(''); + setSearchInputValue(''); + }, []); + + const addFilter = React.useCallback( + (filterValue: FilterValue) => { + if (filter === null) { + console.error('addFilter called with', filter); + return false; + } + const def = filters?.match !== 'any' ? swapFilterDefinition(filterDefinitions, filter, 'src') : filter; + const newFilters = _.cloneDeep(filters?.list) || []; + const not = compare === FilterCompare.notEqual; + const moreThan = compare === FilterCompare.moreThanOrEqual; + const found = findFromFilters(newFilters, { def, not, moreThan }); + 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, not, moreThan, values: [filterValue] }); + } + setFilters({ ...filters!, list: newFilters }); + setAdvancedSearchOpen(false); + reset(); + return true; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [filter, filters, filterDefinitions, compare, setFilters, setMessage] + ); + + const updateForm = React.useCallback( + (submitOnRefresh?: boolean) => { + // parse search input value to form content + let fieldValue: string[] = []; + + if (searchInputValue.includes('!=')) { + fieldValue = searchInputValue.replace('!=', '|').split('|'); + setCompare(FilterCompare.notEqual); + } else if (searchInputValue.includes('>=')) { + fieldValue = searchInputValue.replace('>=', '|').split('|'); + setCompare(FilterCompare.moreThanOrEqual); + } else if (searchInputValue.includes('=')) { + fieldValue = searchInputValue.replace('=', '|').split('|'); + setCompare(FilterCompare.equal); + } else { + setValue(searchInputValue); + } + + if (fieldValue.length == 2) { + const searchValue = fieldValue[0].toLowerCase(); + if (searchValue.startsWith('src')) { + setDirection('source'); + } else if (searchValue.startsWith('dst')) { + setDirection('destination'); + } else { + setDirection(undefined); + } + + const def = filterDefinitions.find(def => def.id.toLowerCase() === searchValue); + if (def) { + setFilter(def); + } + setValue(fieldValue[1]); + } + + if (submitOnRefresh) { + setSubmitPending(true); + } + }, + [filterDefinitions, searchInputValue] + ); + + const getEncodedValue = React.useCallback(() => { + if (filter === null) { + return ''; + } + return matcher(filter.id, [value], compare === FilterCompare.notEqual, compare === FilterCompare.moreThanOrEqual); + }, [compare, filter, value]); + + const onToggle = React.useCallback(() => { + if (isAdvancedSearchOpen) { + setTimeout(() => { + updateForm(); + }); + } + setAdvancedSearchOpen(!isAdvancedSearchOpen); + }, [isAdvancedSearchOpen, updateForm]); + + const searchInput = React.useCallback( + () => ( + { + setSearchInputValue(v); + }} + onSearch={(e, v) => { + if (_.isEmpty(v)) { + setAdvancedSearchOpen(true); + } else { + setSearchInputValue(v); + updateForm(true); + } + }} + onToggleAdvancedSearch={onToggle} + value={isAdvancedSearchOpen ? getEncodedValue() : searchInputValue} + isAdvancedSearchOpen={isAdvancedSearchOpen} + placeholder={filter?.hint} + ref={searchInputRef} + id="filter-search-input" + /> + ), + [filter?.hint, getEncodedValue, isAdvancedSearchOpen, onToggle, reset, searchInputValue, updateForm] + ); + + const advancedForm = React.useCallback(() => { + if (!filter) { + return <>; + } + + return ( +
+ + + +
+ + + + + + + + + {filter.component === 'autocomplete' ? ( + + ) : ( + + )} + + + + + +
+
+
+
+
+ ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + addFilter, + advancedSearchPaneRef, + compare, + direction, + filter, + filterDefinitions, + indicator, + reset, + setMessage, + value + ]); + + React.useEffect(() => { + if (submitPending) { + setSubmitPending(false); + addFilter({ v: value }); + } + }, [submitPending, setSubmitPending, addFilter, value]); + + if (filter == null) { + return <>; + } + // Popper is just one way to build a relationship between a toggle and a menu. + return ( + document.querySelector('#filter-search-input')!} + /> + ); +}; + +export default FilterSearchInput; diff --git a/web/src/components/toolbar/filters/filters-chips.tsx b/web/src/components/toolbar/filters/filters-chips.tsx index 86faa8912..6454bdf9b 100644 --- a/web/src/components/toolbar/filters/filters-chips.tsx +++ b/web/src/components/toolbar/filters/filters-chips.tsx @@ -37,7 +37,6 @@ import { QuickFilter } from '../../../model/quick-filters'; import { autoCompleteCache } from '../../../utils/autocomplete-cache'; import { bnfFilterValue, - getFilterFullName, hasSrcAndDstFilters, hasSrcOrDstFilters, setTargeteableFilters, @@ -138,7 +137,7 @@ export const FiltersChips: React.FC = ({ const getFilterDisplay = React.useCallback( (chipFilter: Filter, cfIndex: number) => { - let fullName = filters.match === 'any' ? getFilterFullName(chipFilter.def, t) : chipFilter.def.name; + let fullName = chipFilter.def.name; if (chipFilter.not) { fullName = t('Not') + ' ' + fullName; } diff --git a/web/src/components/toolbar/filters/filters-dropdown.tsx b/web/src/components/toolbar/filters/filters-dropdown.tsx index 585468c2d..318402a87 100644 --- a/web/src/components/toolbar/filters/filters-dropdown.tsx +++ b/web/src/components/toolbar/filters/filters-dropdown.tsx @@ -1,29 +1,39 @@ -import { Dropdown, DropdownItem, MenuToggle, MenuToggleElement } from '@patternfly/react-core'; +import { Dropdown, DropdownItem, Flex, FlexItem, MenuToggle, MenuToggleElement } from '@patternfly/react-core'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import { FilterDefinition } from '../../../model/filters'; -import { 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 ref = useOutsideClickEvent(() => setOpen(false)); - const [isOpen, setOpen] = React.useState(false); + + const directionRef = useOutsideClickEvent(() => setColumnOpen(false)); + const [isDirectionOpen, setDirectionOpen] = React.useState(false); + + const columnRef = useOutsideClickEvent(() => setColumnOpen(false)); + const [isColumnOpen, setColumnOpen] = React.useState(false); const getFiltersDropdownItems = () => { return filterDefinitions - .filter(f => !f.category || f.category === 'targeteable') + .filter(f => (selectedDirection ? f.category === selectedDirection : !f.category || f.category === 'targeteable')) + .sort((a, b) => a.name.localeCompare(b.name)) .map((f, index) => ( = ({ className={`column-filter-item`} component="button" onClick={() => { - setOpen(false); + setColumnOpen(false); setSelectedFilter(f); }} key={index} @@ -41,29 +51,111 @@ export const FiltersDropdown: React.FC = ({ )); }; + 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('Any direction')} + + )} + > + { - setOpen(!isOpen); + setDirectionOpen(false); + setSelectedDirection('source'); }} - isExpanded={isOpen} > - {getFilterFullName(selectedFilter, t)} - - )} - > - {getFiltersDropdownItems()} - -
+ {t('Source')} +
+ { + setDirectionOpen(false); + setSelectedDirection('destination'); + }} + > + {t('Destination')} + + { + setDirectionOpen(false); + setSelectedDirection(undefined); + }} + > + {t('Any direction')} + + + + + ) => ( + { + setColumnOpen(!isColumnOpen); + }} + isExpanded={isColumnOpen} + > + {selectedFilter.name} + + )} + > + {getFiltersDropdownItems()} + + + ); }; diff --git a/web/src/components/toolbar/filters/text-filter.tsx b/web/src/components/toolbar/filters/text-filter.tsx index 6f5ec6bf4..acb02e7a7 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,24 +8,27 @@ 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 @@ -46,14 +48,14 @@ export const TextFilter: React.FC = ({ } setCurrentValue(filteredValue); }, - [regexp] + [regexp, setCurrentValue] ); const resetFilterValue = React.useCallback(() => { setCurrentValue(''); - setMessageWithDelay(undefined); + setMessage(undefined); setIndicator(ValidatedOptions.default); - }, [setCurrentValue, setMessageWithDelay, setIndicator]); + }, [setCurrentValue, setMessage, setIndicator]); const onSelect = React.useCallback(() => { // override empty value by undefined value @@ -69,7 +71,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 +81,25 @@ export const TextFilter: React.FC = ({ resetFilterValue(); } }); - }, [currentValue, allowEmpty, filterDefinition, setMessageWithDelay, setIndicator, addFilter, resetFilterValue]); + }, [currentValue, allowEmpty, filterDefinition, setMessage, setIndicator, addFilter, resetFilterValue]); return ( - <> - updateValue(value)} - onKeyPress={e => e.key === 'Enter' && onSelect()} - value={currentValue} - ref={searchInputRef} - id="search" - /> - - + updateValue(value)} + onKeyPress={e => { + if (e.key === 'Enter') { + e.preventDefault(); + onSelect(); + } + }} + value={currentValue} + ref={searchInputRef} + id="search" + /> ); }; diff --git a/web/src/utils/filter-definitions.ts b/web/src/utils/filter-definitions.ts index f26ef6074..4286bfd0c 100644 --- a/web/src/utils/filter-definitions.ts +++ b/web/src/utils/filter-definitions.ts @@ -44,7 +44,7 @@ export const undefinedValue = '""'; // Unique double are allowed while typing but invalid export const doubleQuoteValue = '"'; -const matcher = (left: string, right: string[], not: boolean, moreThan: boolean) => +export const matcher = (left: string, right: string[], not: boolean, moreThan: boolean) => `${left}${not ? '!=' : moreThan ? '>=' : '='}${right.join(',')}`; const simpleFiltersEncoder = (field: Field): FiltersEncoder => { From 0e6533a52006565efc05862a5a7dcbed4e50326b Mon Sep 17 00:00:00 2001 From: Julien Pinsonneau Date: Tue, 17 Jun 2025 15:42:22 +0200 Subject: [PATCH 04/17] fix styling --- web/locales/en/plugin__netobserv-plugin.json | 2 +- .../components/toolbar/filters-toolbar.css | 6 - .../toolbar/filters/autocomplete-filter.css | 1 + .../toolbar/filters/compare-filter.tsx | 123 ++++++++++-------- .../toolbar/filters/filter-hints.css | 3 + .../toolbar/filters/filter-hints.tsx | 1 + .../toolbar/filters/filter-search-input.css | 8 +- .../toolbar/filters/filter-search-input.tsx | 2 +- .../toolbar/filters/filters-dropdown.css | 8 +- .../toolbar/filters/filters-dropdown.tsx | 18 +-- 10 files changed, 95 insertions(+), 77 deletions(-) create mode 100644 web/src/components/toolbar/filters/filter-hints.css diff --git a/web/locales/en/plugin__netobserv-plugin.json b/web/locales/en/plugin__netobserv-plugin.json index d4ec7a7f7..82af21855 100644 --- a/web/locales/en/plugin__netobserv-plugin.json +++ b/web/locales/en/plugin__netobserv-plugin.json @@ -361,9 +361,9 @@ "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", + "Equals": "Equals", "Learn more": "Learn more", "Filter already exists": "Filter already exists", "Filter": "Filter", diff --git a/web/src/components/toolbar/filters-toolbar.css b/web/src/components/toolbar/filters-toolbar.css index c2a9aa1f0..987588d5b 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; diff --git a/web/src/components/toolbar/filters/autocomplete-filter.css b/web/src/components/toolbar/filters/autocomplete-filter.css index f30c5fa62..af8d339d1 100644 --- a/web/src/components/toolbar/filters/autocomplete-filter.css +++ b/web/src/components/toolbar/filters/autocomplete-filter.css @@ -7,4 +7,5 @@ #autocomplete-container { margin: 0; padding: 0; + min-width: 330px; } \ No newline at end of file diff --git a/web/src/components/toolbar/filters/compare-filter.tsx b/web/src/components/toolbar/filters/compare-filter.tsx index 05db0f2c8..936f5bef3 100644 --- a/web/src/components/toolbar/filters/compare-filter.tsx +++ b/web/src/components/toolbar/filters/compare-filter.tsx @@ -1,4 +1,4 @@ -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'; @@ -20,49 +20,23 @@ 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')} - - ]; - - if (component === 'number') { - dropdownItems.push( - onSelect(FilterCompare.moreThanOrEqual)} - > - {t('More than')} - - ); - } - - 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]); - } else { - setValue(filterCompareValues[0]); - } - }, [component, value, setValue]); + const getText = React.useCallback( + (v: FilterCompare) => { + switch (v) { + case FilterCompare.notEqual: + return t('Not equals'); + case FilterCompare.moreThanOrEqual: + return t('More than'); + case FilterCompare.equal: + default: + return t('Equals'); + } + }, + [t] + ); - const getSymbol = React.useCallback(() => { - switch (value) { + const getSymbol = React.useCallback((v: FilterCompare) => { + switch (v) { case FilterCompare.notEqual: return '!='; case FilterCompare.moreThanOrEqual: @@ -71,7 +45,53 @@ export const CompareFilter: React.FC = ({ value, setValue, c default: return '='; } - }, [value]); + }, []); + + const onSelect = React.useCallback( + (v: FilterCompare) => { + setValue(v); + setOpen(false); + }, + [setValue] + ); + + const getItems = React.useCallback(() => { + const dropdownItems = [ + onSelect(FilterCompare.equal)} + > + {getSymbol(FilterCompare.equal)} + , + onSelect(FilterCompare.notEqual)} + > + {getSymbol(FilterCompare.notEqual)} + + ]; + + if (component === 'number') { + dropdownItems.push( + onSelect(FilterCompare.moreThanOrEqual)} + > + {getSymbol(FilterCompare.moreThanOrEqual)} + + ); + } + return dropdownItems; + }, [component, getSymbol, getText, onSelect]); React.useEffect(() => { // reset to equal when component change @@ -89,21 +109,16 @@ export const CompareFilter: React.FC = ({ value, setValue, c - {getSymbol()} - - ] - }} + badge={{getSymbol(value)}} onClick={() => setOpen(!isOpen)} isExpanded={isOpen} onBlur={() => setTimeout(() => setOpen(false), 500)} - /> + > + {getText(value)} + )} > - {dropdownItems} + {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 index 403d0a052..162a48f96 100644 --- a/web/src/components/toolbar/filters/filter-search-input.css +++ b/web/src/components/toolbar/filters/filter-search-input.css @@ -1,3 +1,7 @@ -#filter-search-input { - min-width: 500px !important; +#filter-search-input, #filter-search-form { + 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 index 151b7eff0..b4057d865 100644 --- a/web/src/components/toolbar/filters/filter-search-input.tsx +++ b/web/src/components/toolbar/filters/filter-search-input.tsx @@ -187,7 +187,7 @@ export const FilterSearchInput: React.FC = ({ } return ( -
+ diff --git a/web/src/model/topology.ts b/web/src/model/topology.ts index 75036b6b8..b363c740f 100644 --- a/web/src/model/topology.ts +++ b/web/src/model/topology.ts @@ -23,7 +23,7 @@ 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; @@ -284,6 +285,7 @@ const generateNode = ( filtered, highlighted, isDark, + match: filters.match, isSrcFiltered, isDstFiltered, labelPosition: LabelPosition.bottom, From ffe1128f3478757f8165a796ee4b4c304d4abb9f Mon Sep 17 00:00:00 2001 From: Julien Pinsonneau Date: Wed, 18 Jun 2025 15:38:19 +0200 Subject: [PATCH 06/17] explicitly show and / or --- web/locales/en/plugin__netobserv-plugin.json | 3 + .../components/toolbar/filters-toolbar.css | 4 +- .../toolbar/filters/filters-chips.css | 22 ++ .../toolbar/filters/filters-chips.tsx | 276 ++++++++++-------- 4 files changed, 182 insertions(+), 123 deletions(-) diff --git a/web/locales/en/plugin__netobserv-plugin.json b/web/locales/en/plugin__netobserv-plugin.json index 8bb5a66db..7ff79825f 100644 --- a/web/locales/en/plugin__netobserv-plugin.json +++ b/web/locales/en/plugin__netobserv-plugin.json @@ -372,6 +372,8 @@ "Comparator": "Comparator", "Add filter": "Add filter", "Peer": "Peer", + "OR": "OR", + "AND": "AND", "Not": "Not", "more than": "more than", "Disable": "Disable", @@ -383,6 +385,7 @@ "As peer B": "As peer B", "As destination": "As destination", "Remove": "Remove", + "Match": "Match", "Edit filters": "Edit filters", "Reset defaults": "Reset defaults", "Clear all": "Clear all", diff --git a/web/src/components/toolbar/filters-toolbar.css b/web/src/components/toolbar/filters-toolbar.css index 987588d5b..f64e98bbb 100644 --- a/web/src/components/toolbar/filters-toolbar.css +++ b/web/src/components/toolbar/filters-toolbar.css @@ -65,7 +65,7 @@ div#filter-toolbar-search-filters { .custom-chip-box { padding: 0.2em 0.5em 0.2em 0.5em; - margin: 0 1em 0 0; + margin: 0; flex-direction: column; } @@ -76,7 +76,7 @@ div#filter-toolbar-search-filters { .custom-chip-group { padding: 0.2em 0.5em 0.2em 0.5em; - margin: 0 1em 0 0; + margin: 0; color: #000; background-color: #f0f0f0; } diff --git a/web/src/components/toolbar/filters/filters-chips.css b/web/src/components/toolbar/filters/filters-chips.css index 85d3cec9e..2848acc5a 100644 --- a/web/src/components/toolbar/filters/filters-chips.css +++ b/web/src/components/toolbar/filters/filters-chips.css @@ -11,4 +11,26 @@ .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 6454bdf9b..a98ca29be 100644 --- a/web/src/components/toolbar/filters/filters-chips.tsx +++ b/web/src/components/toolbar/filters/filters-chips.tsx @@ -3,6 +3,8 @@ import { Dropdown, DropdownItem, DropdownList, + Flex, + FlexItem, MenuToggle, MenuToggleElement, Text, @@ -135,6 +137,21 @@ export const FiltersChips: React.FC = ({ [filterDefinitions, filters, setFilters] ); + const getAndOrText = React.useCallback( + (index: number) => { + if (index == 0) { + return undefined; + } + + return ( + + {filters.match === 'any' ? t('OR') : t('AND')} + + ); + }, + [filters.match, t] + ); + const getFilterDisplay = React.useCallback( (chipFilter: Filter, cfIndex: number) => { let fullName = chipFilter.def.name; @@ -146,130 +163,133 @@ export const FiltersChips: React.FC = ({ } 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) => { - if (isForced || chipFilterValue.disabled) { +
+ {getAndOrText(cfIndex)} +
+ + { + //switch all values if no remaining + chipFilter.values.forEach(fv => { + fv.disabled = someEnabled; + }); + setFilters(_.cloneDeep(filters)); + }} + > + {fullName} + + + {chipFilter.values.map((chipFilterValue, fvIndex) => { + if (isForced || chipFilterValue.disabled) { + return ( +
+ + { + chipFilterValue.disabled = !chipFilterValue.disabled; + setFilters(_.cloneDeep(filters)); + }} + > + {chipFilterValue.display ? chipFilterValue.display : chipFilterValue.v} + + +
+ ); + } + + const dropdownId = `${chipFilter.def.id}-${fvIndex}`; return ( -
- - setOpenedDropdown(isOpen ? dropdownId : undefined)} + toggle={(toggleRef: React.Ref) => ( + setOpenedDropdown(openedDropdown === dropdownId ? undefined : dropdownId)} + > + {chipFilterValue.display ? chipFilterValue.display : chipFilterValue.v} + + )} + > + + { chipFilterValue.disabled = !chipFilterValue.disabled; setFilters(_.cloneDeep(filters)); + setOpenedDropdown(undefined); }} > - {chipFilterValue.display ? chipFilterValue.display : chipFilterValue.v} - - -
- ); - } - - const dropdownId = `${chipFilter.def.id}-${fvIndex}`; - return ( - setOpenedDropdown(isOpen ? dropdownId : undefined)} - toggle={(toggleRef: React.Ref) => ( - setOpenedDropdown(openedDropdown === dropdownId ? undefined : dropdownId)} - > - {chipFilterValue.display ? chipFilterValue.display : chipFilterValue.v} - - )} - > - - { - chipFilterValue.disabled = !chipFilterValue.disabled; - setFilters(_.cloneDeep(filters)); - setOpenedDropdown(undefined); - }} - > - {chipFilterValue.disabled && } - {!chipFilterValue.disabled && } -  {chipFilterValue.disabled ? t('Enable') : t('Disable')} - - {filters.match !== 'peers' && - (chipFilter.def.id.startsWith('src_') || chipFilter.def.id.startsWith('dst_')) && ( - { - const bnf = bnfFilterValue( - filterDefinitions, - filters!.list, - chipFilter.def.id, - chipFilterValue - ); - setFilters({ ...filters!, list: bnf }); - setOpenedDropdown(undefined); - }} - > - -  {t('Any')} + {chipFilterValue.disabled && } + {!chipFilterValue.disabled && } +  {chipFilterValue.disabled ? t('Enable') : t('Disable')} + + {filters.match !== 'peers' && + (chipFilter.def.id.startsWith('src_') || chipFilter.def.id.startsWith('dst_')) && ( + { + const bnf = bnfFilterValue( + filterDefinitions, + filters!.list, + chipFilter.def.id, + chipFilterValue + ); + setFilters({ ...filters!, list: bnf }); + setOpenedDropdown(undefined); + }} + > + +  {t('Any')} + + )} + {(chipFilter.def.category === 'targeteable' || chipFilter.def.id.startsWith('dst_')) && ( + swapValue(chipFilter, chipFilterValue, 'src')}> + +  {filters.match === 'peers' ? t('As peer A') : t('As source')} )} - {(chipFilter.def.category === 'targeteable' || chipFilter.def.id.startsWith('dst_')) && ( - swapValue(chipFilter, chipFilterValue, 'src')}> - -  {filters.match === 'peers' ? t('As peer A') : t('As source')} - - )} - {(chipFilter.def.category === 'targeteable' || chipFilter.def.id.startsWith('src_')) && ( - swapValue(chipFilter, chipFilterValue, 'dst')}> - -  {filters.match === 'peers' ? t('As peer B') : t('As destination')} + {(chipFilter.def.category === 'targeteable' || chipFilter.def.id.startsWith('src_')) && ( + swapValue(chipFilter, chipFilterValue, 'dst')}> + +  {filters.match === 'peers' ? t('As peer B') : t('As destination')} + + )} + { + chipFilter.values = chipFilter.values.filter(val => val.v !== chipFilterValue.v); + if (_.isEmpty(chipFilter.values)) { + setFiltersList(removeFromFilters(filters.list, chipFilter)); + } else { + setFilters(_.cloneDeep(filters)); + } + setOpenedDropdown(undefined); + }} + > + +  {t('Remove')} - )} - { - chipFilter.values = chipFilter.values.filter(val => val.v !== chipFilterValue.v); - if (_.isEmpty(chipFilter.values)) { - setFiltersList(removeFromFilters(filters.list, chipFilter)); - } else { - setFilters(_.cloneDeep(filters)); - } - setOpenedDropdown(undefined); - }} - > - -  {t('Remove')} - - - - ); - })} - {!isForced && ( - - )} + + + ); + })} + {!isForced && ( + + )} +
); }, @@ -301,16 +321,30 @@ export const FiltersChips: React.FC = ({ id={`${isForced ? 'forced-' : ''}filters`} variant="filter-group" > - + {(filters.list.length > 2 || hasSrcOrDstFilters(filters.list)) && ( + + + + {t('Match')} + + + + + + + )} {getGroups() .filter(gp => gp.filters.length) - .map(gp => { + .map((gp, index) => { return ( -
- {hasSrcOrDstFilters(filters.list) && {getGroupName(gp.id)} } -
{gp.filters.map(getFilterDisplay)}
-
+ <> + {getAndOrText(index)} +
+ {hasSrcOrDstFilters(filters.list) && {getGroupName(gp.id)} } +
{gp.filters.map(getFilterDisplay)}
+
+ ); })}
From 366446de121878838449fa9f9e99570ccdf23ff8 Mon Sep 17 00:00:00 2001 From: Julien Pinsonneau Date: Thu, 19 Jun 2025 15:58:30 +0200 Subject: [PATCH 07/17] address feedback --- web/locales/en/plugin__netobserv-plugin.json | 16 +- .../components/dropdowns/match-dropdown.tsx | 2 +- .../components/toolbar/filters-toolbar.tsx | 55 +++- .../filters/__tests__/filters-chips.spec.tsx | 1 + .../toolbar/filters/filter-search-input.tsx | 131 +++++---- .../toolbar/filters/filters-chips.tsx | 252 +++++++++++------- .../toolbar/filters/filters-dropdown.tsx | 4 +- 7 files changed, 306 insertions(+), 155 deletions(-) diff --git a/web/locales/en/plugin__netobserv-plugin.json b/web/locales/en/plugin__netobserv-plugin.json index 7ff79825f..8b2aa0672 100644 --- a/web/locales/en/plugin__netobserv-plugin.json +++ b/web/locales/en/plugin__netobserv-plugin.json @@ -73,7 +73,7 @@ "Grid": "Grid", "Invalid": "Invalid", "Any": "Any", - "One way": "One way", + "All": "All", "Peers": "Peers", "rate": "rate", "Average": "Average", @@ -103,7 +103,6 @@ "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\"", @@ -368,10 +367,16 @@ "Equals": "Equals", "Learn more": "Learn more", "Filter already exists": "Filter already exists", + "`>=` is not allowed with `{{searchValue}}`. Use `=` or `!=` instead.": "`>=` is not allowed with `{{searchValue}}`. Use `=` or `!=` 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", @@ -380,18 +385,23 @@ "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 from and to filters": "Swap from and to filters", - "Any direction": "Any direction", + "Common": "Common", "Quick filters": "Quick filters", "More options": "More options", "Export overview": "Export overview", diff --git a/web/src/components/dropdowns/match-dropdown.tsx b/web/src/components/dropdowns/match-dropdown.tsx index 4154aa19f..b0b4d1424 100644 --- a/web/src/components/dropdowns/match-dropdown.tsx +++ b/web/src/components/dropdowns/match-dropdown.tsx @@ -21,7 +21,7 @@ export const MatchDropdown: React.FC = ({ selected, setMatch case 'any': return t('Any'); case 'all': - return t('One way'); + return t('All'); case 'peers': return t('Peers'); default: diff --git a/web/src/components/toolbar/filters-toolbar.tsx b/web/src/components/toolbar/filters-toolbar.tsx index 0ca46c618..05160c7f0 100644 --- a/web/src/components/toolbar/filters-toolbar.tsx +++ b/web/src/components/toolbar/filters-toolbar.tsx @@ -1,14 +1,17 @@ -import { Button, Toolbar, ToolbarContent, ToolbarItem, Tooltip } 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 { FilterDefinition, Filters } from '../../model/filters'; +import { Filter, FilterDefinition, Filters } from '../../model/filters'; import { QuickFilter } from '../../model/quick-filters'; import { autoCompleteCache } from '../../utils/autocomplete-cache'; +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 { FilterCompare } from './filters/compare-filter'; import { FilterSearchInput } from './filters/filter-search-input'; import { FiltersChips } from './filters/filters-chips'; import { QuickFilters } from './filters/quick-filters'; @@ -29,6 +32,8 @@ export interface FiltersToolbarProps { setFullScreen: (b: boolean) => void; } +export type Direction = 'source' | 'destination' | undefined; + export const FiltersToolbar: React.FC = ({ id, filters, @@ -44,7 +49,18 @@ export const FiltersToolbar: React.FC = ({ ...props }) => { const { t } = useTranslation('plugin__netobserv-plugin'); + const [message, setMessage] = 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 [compare, setCompare] = React.useState(FilterCompare.equal); + const [value, setValue] = React.useState(''); const [showFilters, setShowFilters] = useLocalStorage(localStorageShowFiltersKey, true); @@ -63,7 +79,14 @@ export const FiltersToolbar: React.FC = ({ [skipTipsDelay] ); + const editValue = React.useCallback((f: Filter, v: string) => { + setSearchInputValue(matcher(f.def.id, [v], f.not === true, f.moreThan === true)); + }, []); + const getFilterToolbar = React.useCallback(() => { + if (!filter) { + return <>; + } return ( = ({ ); - }, [filterDefinitions, filters, message, setFilters, setMessageWithDelay]); + }, [ + compare, + direction, + filter, + filterDefinitions, + filters, + indicator, + message, + searchInputValue, + setFilters, + setMessageWithDelay, + value + ]); const isForced = !_.isEmpty(forcedFilters); const filtersOrForced = isForced ? forcedFilters : filters; @@ -97,7 +144,6 @@ export const FiltersToolbar: React.FC = ({ } else if (defaultFilters.length > 0) { showHideText = showFilters ? t('Hide filters') : t('Show filters'); } - return ( @@ -154,6 +200,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__/filters-chips.spec.tsx b/web/src/components/toolbar/filters/__tests__/filters-chips.spec.tsx index e90735096..8ae646b94 100644 --- a/web/src/components/toolbar/filters/__tests__/filters-chips.spec.tsx +++ b/web/src/components/toolbar/filters/__tests__/filters-chips.spec.tsx @@ -7,6 +7,7 @@ describe('', () => { const props: FiltersChipsProps = { 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/filter-search-input.tsx b/web/src/components/toolbar/filters/filter-search-input.tsx index b4057d865..c6d5274c6 100644 --- a/web/src/components/toolbar/filters/filter-search-input.tsx +++ b/web/src/components/toolbar/filters/filter-search-input.tsx @@ -14,9 +14,10 @@ import _ from 'lodash'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { FilterDefinition, Filters, FilterValue, findFromFilters } from '../../../model/filters'; -import { findFilter, matcher } from '../../../utils/filter-definitions'; -import { Indicator, swapFilterDefinition } from '../../../utils/filters-helper'; +import { matcher } from '../../../utils/filter-definitions'; +import { Indicator, setTargeteableFilters } from '../../../utils/filters-helper'; import { useOutsideClickEvent } from '../../../utils/outside-hook'; +import { Direction } from '../filters-toolbar'; import AutocompleteFilter from './autocomplete-filter'; import CompareFilter, { FilterCompare } from './compare-filter'; import { FilterHints } from './filter-hints'; @@ -27,6 +28,18 @@ import TextFilter from './text-filter'; 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; } @@ -34,21 +47,24 @@ export interface FilterSearchInputProps { 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 [direction, setDirection] = React.useState<'source' | 'destination'>(); - const [filter, setFilter] = React.useState( - findFilter(filterDefinitions, 'src_namespace') || filterDefinitions.length ? filterDefinitions[0] : null - ); - const [compare, setCompare] = React.useState(FilterCompare.equal); - const [value, setValue] = React.useState(''); - const [indicator, setIndicator] = React.useState(ValidatedOptions.default); - const searchInputRef = React.useRef(null); - const [searchInputValue, setSearchInputValue] = React.useState(''); const advancedSearchPaneRef = useOutsideClickEvent(() => { setSearchInputValue(getEncodedValue()); setAdvancedSearchOpen(false); @@ -64,7 +80,7 @@ export const FilterSearchInput: React.FC = ({ setCompare(FilterCompare.equal); setValue(''); setSearchInputValue(''); - }, []); + }, [setCompare, setSearchInputValue, setValue]); const addFilter = React.useCallback( (filterValue: FilterValue) => { @@ -72,8 +88,8 @@ export const FilterSearchInput: React.FC = ({ console.error('addFilter called with', filter); return false; } - const def = filters?.match !== 'any' ? swapFilterDefinition(filterDefinitions, filter, 'src') : filter; - const newFilters = _.cloneDeep(filters?.list) || []; + let newFilters = _.cloneDeep(filters?.list) || []; + const def = filter; const not = compare === FilterCompare.notEqual; const moreThan = compare === FilterCompare.moreThanOrEqual; const found = findFromFilters(newFilters, { def, not, moreThan }); @@ -88,6 +104,11 @@ export const FilterSearchInput: React.FC = ({ } else { newFilters.push({ def, not, moreThan, 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 }); setAdvancedSearchOpen(false); reset(); @@ -100,43 +121,57 @@ export const FilterSearchInput: React.FC = ({ const updateForm = React.useCallback( (submitOnRefresh?: boolean) => { // parse search input value to form content - let fieldValue: string[] = []; - - if (searchInputValue.includes('!=')) { - fieldValue = searchInputValue.replace('!=', '|').split('|'); - setCompare(FilterCompare.notEqual); - } else if (searchInputValue.includes('>=')) { - fieldValue = searchInputValue.replace('>=', '|').split('|'); - setCompare(FilterCompare.moreThanOrEqual); - } else if (searchInputValue.includes('=')) { - fieldValue = searchInputValue.replace('=', '|').split('|'); - setCompare(FilterCompare.equal); - } else { - setValue(searchInputValue); - } + const fieldValue = searchInputValue.replaceAll('!=', '|').replaceAll('>=', '|').replaceAll('=', '|').split('|'); + // if field + value are valid, we should end with 2 items only if (fieldValue.length == 2) { const searchValue = fieldValue[0].toLowerCase(); - if (searchValue.startsWith('src')) { - setDirection('source'); - } else if (searchValue.startsWith('dst')) { - setDirection('destination'); - } else { - setDirection(undefined); - } - const def = filterDefinitions.find(def => def.id.toLowerCase() === searchValue); if (def) { + // set compare + if (searchInputValue.includes('>=')) { + if (def.component != 'number') { + setMessage(t('`>=` is not allowed with `{{searchValue}}`. Use `=` or `!=` instead.', { searchValue })); + setIndicator(ValidatedOptions.error); + return; + } + setCompare(FilterCompare.moreThanOrEqual); + } else if (searchInputValue.includes('!=')) { + setCompare(FilterCompare.notEqual); + } else { + setCompare(FilterCompare.equal); + } + // set direction + if (searchValue.startsWith('src')) { + setDirection('source'); + } else if (searchValue.startsWith('dst')) { + setDirection('destination'); + } else { + setDirection(undefined); + } + //set filter setFilter(def); + } else if (submitOnRefresh) { + setMessage(t("Can't find filter `{{searchValue}}`", { searchValue })); + setIndicator(ValidatedOptions.error); + return; } setValue(fieldValue[1]); + } else if (fieldValue.length === 1) { + // set simple value on current filter if no splitter found + setValue(searchInputValue); + } else { + setMessage(t('Invalid format. The input should be such as `name=netobserv`.')); + setIndicator(ValidatedOptions.error); + return; } if (submitOnRefresh) { setSubmitPending(true); } }, - [filterDefinitions, searchInputValue] + // eslint-disable-next-line react-hooks/exhaustive-deps + [compare, filterDefinitions, searchInputValue, setMessage] ); const getEncodedValue = React.useCallback(() => { @@ -147,11 +182,7 @@ export const FilterSearchInput: React.FC = ({ }, [compare, filter, value]); const onToggle = React.useCallback(() => { - if (isAdvancedSearchOpen) { - setTimeout(() => { - updateForm(); - }); - } + updateForm(); setAdvancedSearchOpen(!isAdvancedSearchOpen); }, [isAdvancedSearchOpen, updateForm]); @@ -178,14 +209,19 @@ export const FilterSearchInput: React.FC = ({ id="filter-search-input" /> ), - [filter?.hint, getEncodedValue, isAdvancedSearchOpen, onToggle, reset, searchInputValue, updateForm] + [ + filter?.hint, + getEncodedValue, + isAdvancedSearchOpen, + onToggle, + reset, + searchInputValue, + setSearchInputValue, + updateForm + ] ); const advancedForm = React.useCallback(() => { - if (!filter) { - return <>; - } - return (