diff --git a/pkg/handler/csv/loki_csv.go b/pkg/handler/csv/loki_csv.go index 2be84d407..3c87cb754 100644 --- a/pkg/handler/csv/loki_csv.go +++ b/pkg/handler/csv/loki_csv.go @@ -26,9 +26,13 @@ func GetCSVData(qr *model.QueryResponse, columns []string) ([][]string, error) { func manageStreams(streams model.Streams, columns []string) ([][]string, error) { //make csv datas containing header as first line + rows datas := make([][]string, 1) + //prepare columns for faster lookup + columnsMap := utils.GetMapInterface(columns) //set Timestamp as first data - if columns == nil || utils.Contains(columns, timestampCol) { + includeTimestamp := false + if _, exists := columnsMap[timestampCol]; exists || len(columns) == 0 { datas[0] = append(datas[0], timestampCol) + includeTimestamp = true } //keep ordered labels / field names between each lines //filtered by columns parameter if specified @@ -39,7 +43,7 @@ func manageStreams(streams model.Streams, columns []string) ([][]string, error) if labels == nil { labels = make([]string, 0, len(stream.Labels)) for name := range stream.Labels { - if columns == nil || utils.Contains(columns, name) { + if _, exists := columnsMap[name]; exists || len(columns) == 0 { labels = append(fields, name) } } @@ -59,39 +63,36 @@ func manageStreams(streams model.Streams, columns []string) ([][]string, error) if fields == nil { fields = make([]string, 0, len(line)) for name := range line { - if columns == nil || utils.Contains(columns, name) { + if _, exists := columnsMap[name]; exists || len(columns) == 0 { fields = append(fields, name) } } datas[0] = append(datas[0], fields...) } - datas = append(datas, getRowDatas(stream, entry, labels, fields, line, len(datas[0]), columns)) + datas = append(datas, getRowDatas(stream, entry, labels, fields, line, len(datas[0]), includeTimestamp)) } } return datas, nil } -func getRowDatas(stream model.Stream, entry model.Entry, labels []string, fields []string, - line map[string]interface{}, size int, columns []string) []string { - index := 0 - rowDatas := make([]string, size) +func getRowDatas(stream model.Stream, entry model.Entry, labels, fields []string, + line map[string]interface{}, size int, includeTimestamp bool) []string { + rowDatas := make([]string, 0, size) //set timestamp - if columns == nil || utils.Contains(columns, timestampCol) { - rowDatas[index] = entry.Timestamp.String() + if includeTimestamp { + rowDatas = append(rowDatas, entry.Timestamp.String()) } //set labels values for _, label := range labels { - index++ - rowDatas[index] = stream.Labels[label] + rowDatas = append(rowDatas, stream.Labels[label]) } //set field values for _, field := range fields { - index++ - rowDatas[index] = fmt.Sprintf("%v", line[field]) + rowDatas = append(rowDatas, fmt.Sprint(line[field])) } return rowDatas diff --git a/pkg/handler/loki.go b/pkg/handler/loki.go index eec2852f8..a71229daf 100644 --- a/pkg/handler/loki.go +++ b/pkg/handler/loki.go @@ -45,23 +45,13 @@ func GetFlows(cfg LokiConfig, allowExport bool) func(w http.ResponseWriter, r *h // - manage range (check RANGE_SPLIT_CHAR on front side) return func(w http.ResponseWriter, r *http.Request) { params := r.URL.Query() - // TODO: remove all logs - hlog.Infof("GetFlows query params : %s", params) + hlog.Debugf("GetFlows query params: %s", params) //allow export only on specific endpoints queryBuilder := loki.NewQuery(cfg.Labels, allowExport) - for key, param := range params { - var val string - if len(param) > 0 { - val = param[0] - } - - if len(val) > 0 { - if err := queryBuilder.AddParam(key, val); err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - } + if err := queryBuilder.AddParams(params); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return } queryBuilder, err := queryBuilder.PrepareToSubmit() if err != nil { @@ -75,7 +65,7 @@ func GetFlows(cfg LokiConfig, allowExport bool) func(w http.ResponseWriter, r *h return } flowsURL := strings.TrimRight(cfg.URL.String(), "/") + getFlowsURLPath + "?" + query - hlog.Infof("GetFlows URL: %s", flowsURL) + hlog.Debugf("GetFlows URL: %s", flowsURL) resp, code, err := lokiClient.Get(flowsURL) if err != nil { diff --git a/pkg/loki/query.go b/pkg/loki/query.go index 2f6a16e2d..c96d574ad 100644 --- a/pkg/loki/query.go +++ b/pkg/loki/query.go @@ -4,6 +4,7 @@ package loki import ( "errors" "fmt" + "net/url" "regexp" "strconv" "strings" @@ -140,6 +141,21 @@ func (q *Query) WriteLabelFilter(sb *strings.Builder, lfs *[]labelFilter, lj Lab } } +func (q *Query) AddParams(params url.Values) error { + for key, values := range params { + if len(values) == 0 { + // Silently ignore + continue + } + + // Note: empty string allowed + if err := q.AddParam(key, values[0]); err != nil { + return err + } + } + return nil +} + func (q *Query) AddParam(key, value string) error { if !filterRegexpValidation.MatchString(value) { return fmt.Errorf("unauthorized sign in flows request: %s", value) diff --git a/web/locales/en/plugin__network-observability-plugin.json b/web/locales/en/plugin__network-observability-plugin.json index 10d475609..b1c0d6008 100644 --- a/web/locales/en/plugin__network-observability-plugin.json +++ b/web/locales/en/plugin__network-observability-plugin.json @@ -2,14 +2,6 @@ "Compact": "Compact", "Normal": "Normal", "Large": "Large", - "Column must be selected": "Column must be selected", - "Value is empty": "Value is empty", - "Unknown port": "Unknown port", - "Not a valid IPv4 or IPv6, nor a CIDR, nor an IP range separated by hyphen": "Not a valid IPv4 or IPv6, nor a CIDR, nor an IP range separated by hyphen", - "Unknown protocol": "Unknown protocol", - "Not a valid kubernetes label": "Not a valid kubernetes label", - "You must select an existing kubernetes object from autocomplete": "You must select an existing kubernetes object from autocomplete", - "Filter already exists": "Filter already exists", "Specify a single port number or name.": "Specify a single port number or name.", "Specify a single port following one of these rules:": "Specify a single port following one of these rules:", "A port number like 80, 21": "A port number like 80, 21", @@ -44,6 +36,15 @@ "A range within the IP address like 192.168.0.1-192.189.10.12:8080": "A range within the IP address like 192.168.0.1-192.189.10.12:8080", "A CIDR specification like 192.51.100.0/24:8080": "A CIDR specification like 192.51.100.0/24:8080", "Learn more": "Learn more", + "Column must be selected": "Column must be selected", + "Value is empty. For an empty exact match, type \"\"": "Value is empty. For an empty exact match, type \"\"", + "Value is empty": "Value is empty", + "Unknown port": "Unknown port", + "Not a valid IPv4 or IPv6, nor a CIDR, nor an IP range separated by hyphen": "Not a valid IPv4 or IPv6, nor a CIDR, nor an IP range separated by hyphen", + "Unknown protocol": "Unknown protocol", + "Not a valid Kubernetes name": "Not a valid Kubernetes name", + "You must select an existing kubernetes object from autocomplete": "You must select an existing kubernetes object from autocomplete", + "Filter already exists": "Filter already exists", "Common": "Common", "Clear all filters": "Clear all filters", "Edit filters": "Edit filters", diff --git a/web/src/components/filter-hints.tsx b/web/src/components/filter-hints.tsx new file mode 100644 index 000000000..d0df388b7 --- /dev/null +++ b/web/src/components/filter-hints.tsx @@ -0,0 +1,87 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Popover, Text, TextVariants } from '@patternfly/react-core'; +import * as _ from 'lodash'; +import { FilterType } from '../utils/filters'; + +interface FilterHintsProps { + type: FilterType; + name: string; +} + +export const FilterHints: React.FC = ({ type, name }) => { + const { t } = useTranslation('plugin__network-observability-plugin'); + let hint = ''; + let examples = ''; + switch (type) { + case FilterType.PORT: + hint = t('Specify a single port number or name.'); + examples = `${t('Specify a single port following one of these rules:')} + - ${t('A port number like 80, 21')} + - ${t('A IANA name like HTTP, FTP')}`; + break; + case FilterType.ADDRESS: + hint = t('Specify a single address or range.'); + examples = `${t('Specify addresses following one of these rules:')} + - ${t('A single IPv4 or IPv6 address like 192.0.2.0, ::1')} + - ${t('A range within the IP address like 192.168.0.1-192.189.10.12, 2001:db8::1-2001:db8::8')} + - ${t('A CIDR specification like 192.51.100.0/24, 2001:db8::/32')}`; + break; + case FilterType.PROTOCOL: + hint = t('Specify a single protocol number or name.'); + examples = `${t('Specify a single protocol following one of these rules:')} + - ${t('A protocol number like 6, 17')} + - ${t('A IANA name like TCP, UDP')}`; + break; + case FilterType.NAMESPACE: + case FilterType.K8S_OBJECT: + case FilterType.K8S_NAMES: + hint = t('Specify a single kubernetes name.'); + examples = `${t('Specify a single kubernetes name following these rules:')} + - ${t('Containing any alphanumeric, hyphen, underscrore or dot character')} + - ${t('Partial text like cluster, cluster-image, image-registry')} + - ${t('Exact match using quotes like "cluster-image-registry"')} + - ${t('Case sensitive match using quotes like "Deployment"')} + - ${t('Starting text like cluster, "cluster-*"')} + - ${t('Ending text like "*-registry"')} + - ${t('Pattern like "cluster-*-registry", "c*-*-r*y", -i*e-')}`; + break; + case FilterType.KIND_NAMESPACE_NAME: + hint = t('Specify an existing object from its kind and namespace.'); + examples = `${t('Specify a kind, namespace and name from existing:')} + - ${t('Select kind first from suggestions')} + - ${t('Then Select namespace from suggestions')} + - ${t('Finally select object from suggestions')} + ${t('You can also directly specify a kind namespace and name like pod.openshift.apiserver')}`; + break; + case FilterType.ADDRESS_PORT: + hint = t('Specify a single address or range with port'); + examples = `${t('Specify addresses and port following one of these rules:')} + - ${t('A single IPv4 address with port like 192.0.2.0:8080')} + - ${t('A range within the IP address like 192.168.0.1-192.189.10.12:8080')} + - ${t('A CIDR specification like 192.51.100.0/24:8080')}`; + break; + default: + hint = ''; + examples = ''; + break; + } + return ( +
+ {hint} + {!_.isEmpty(examples) ? ( + {examples}
} + hasAutoWidth={true} + position={'bottom'} + > + + + ) : undefined} + + ); +}; diff --git a/web/src/components/filters-toolbar.tsx b/web/src/components/filters-toolbar.tsx index 84eb8dfe8..34adb6fb4 100644 --- a/web/src/components/filters-toolbar.tsx +++ b/web/src/components/filters-toolbar.tsx @@ -17,11 +17,8 @@ import { NumberInput, OverflowMenu, OverflowMenuGroup, - Popover, Popper, - Text, TextInput, - TextVariants, Toolbar, ToolbarContent, ToolbarFilter, @@ -56,8 +53,10 @@ import { QueryOptions } from '../model/query-options'; import { QueryOptionsDropdown } from './query-options-dropdown'; import { getPathWithParams, NETFLOW_TRAFFIC_PATH } from '../utils/router'; import { useHistory } from 'react-router-dom'; -import { validateLabel } from '../utils/label'; +import { validateK8SName } from '../utils/label'; import { getNamespaces, getResources } from '../api/routes'; +import { getHTTPErrorDetails } from '../utils/errors'; +import { FilterHints } from './filter-hints'; export interface FiltersToolbarProps { id: string; @@ -155,10 +154,13 @@ export const FiltersToolbar: React.FC = ({ if (!selectedFilterColumn) { return { err: t('Column must be selected') }; } else if (_.isEmpty(value)) { + if (selectedFilterColumn.filterType === FilterType.NAMESPACE) { + return { err: t('Value is empty. For an empty exact match, type ""') }; + } return { err: t('Value is empty') }; } - switch (selectedFilterColumn?.filterType) { + switch (selectedFilterColumn.filterType) { case FilterType.PORT: //allow any port number or valid name / value if (!isNaN(Number(value)) || getPort(value)) { @@ -183,7 +185,7 @@ export const FiltersToolbar: React.FC = ({ } case FilterType.NAMESPACE: case FilterType.K8S_NAMES: - return validateLabel(value) ? { val: value } : { err: t('Not a valid kubernetes label') }; + return value === '""' || validateK8SName(value) ? { val: value } : { err: t('Not a valid Kubernetes name') }; case FilterType.KIND: case FilterType.KIND_NAMESPACE_NAME: return { err: t('You must select an existing kubernetes object from autocomplete') }; @@ -408,82 +410,6 @@ export const FiltersToolbar: React.FC = ({ return filters?.some(f => f?.values?.length); }, [filters]); - const getHint = () => { - let hint = ''; - let examples = ''; - switch (selectedFilterColumn.filterType) { - case FilterType.PORT: - hint = t('Specify a single port number or name.'); - examples = `${t('Specify a single port following one of these rules:')} - - ${t('A port number like 80, 21')} - - ${t('A IANA name like HTTP, FTP')}`; - break; - case FilterType.ADDRESS: - hint = t('Specify a single address or range.'); - examples = `${t('Specify addresses following one of these rules:')} - - ${t('A single IPv4 or IPv6 address like 192.0.2.0, ::1')} - - ${t('A range within the IP address like 192.168.0.1-192.189.10.12, 2001:db8::1-2001:db8::8')} - - ${t('A CIDR specification like 192.51.100.0/24, 2001:db8::/32')}`; - break; - case FilterType.PROTOCOL: - hint = t('Specify a single protocol number or name.'); - examples = `${t('Specify a single protocol following one of these rules:')} - - ${t('A protocol number like 6, 17')} - - ${t('A IANA name like TCP, UDP')}`; - break; - case FilterType.NAMESPACE: - case FilterType.K8S_OBJECT: - case FilterType.K8S_NAMES: - hint = t('Specify a single kubernetes name.'); - examples = `${t('Specify a single kubernetes name following these rules:')} - - ${t('Containing any alphanumeric, hyphen, underscrore or dot character')} - - ${t('Partial text like cluster, cluster-image, image-registry')} - - ${t('Exact match using quotes like "cluster-image-registry"')} - - ${t('Case sensitive match using quotes like "Deployment"')} - - ${t('Starting text like cluster, "cluster-*"')} - - ${t('Ending text like "*-registry"')} - - ${t('Pattern like "cluster-*-registry", "c*-*-r*y", -i*e-')}`; - break; - case FilterType.KIND_NAMESPACE_NAME: - hint = t('Specify an existing object from its kind and namespace.'); - examples = `${t('Specify a kind, namespace and name from existing:')} - - ${t('Select kind first from suggestions')} - - ${t('Then Select namespace from suggestions')} - - ${t('Finally select object from suggestions')} - ${t('You can also directly specify a kind namespace and name like pod.openshift.apiserver')}`; - break; - case FilterType.ADDRESS_PORT: - hint = t('Specify a single address or range with port'); - examples = `${t('Specify addresses and port following one of these rules:')} - - ${t('A single IPv4 address with port like 192.0.2.0:8080')} - - ${t('A range within the IP address like 192.168.0.1-192.189.10.12:8080')} - - ${t('A CIDR specification like 192.51.100.0/24:8080')}`; - break; - default: - hint = ''; - examples = ''; - break; - } - return ( -
- {hint} - {!_.isEmpty(examples) ? ( - {examples}
} - hasAutoWidth={true} - position={'bottom'} - > - - - ) : undefined} - - ); - }; - const getFiltersDropdownItems = () => { return [ @@ -529,9 +455,14 @@ export const FiltersToolbar: React.FC = ({ case FilterType.KIND_NAMESPACE_NAME: case FilterType.NAMESPACE: // refresh available namespaces and clear pods - getNamespaces().then(ns => { - setNamespaces(ns); - }); + getNamespaces() + .then(ns => { + setNamespaces(ns); + }) + .catch(err => { + const errorMessage = getHTTPErrorDetails(err); + setMessageWithDelay(errorMessage); + }); clearObjects(); break; /*case FilterType.POD: @@ -629,7 +560,7 @@ export const FiltersToolbar: React.FC = ({ - {getHint()} + ) : ( diff --git a/web/src/utils/label.ts b/web/src/utils/label.ts index 3149d062c..ab188a24a 100644 --- a/web/src/utils/label.ts +++ b/web/src/utils/label.ts @@ -1,13 +1,13 @@ -/* validate any k8s partial label +/* validate any k8s partial name * can start / end with quotes * allow upper / lower case alphanumeric * allow '*' / '-' / '_' / '.' chars * don't force to start / end with alphanumeric since we can filter on partial names */ -export const k8sLabel = RegExp('^["]{0,1}[A-Za-z0-9*-_.]{1,}?["]{0,1}$'); +export const k8sName = RegExp('^["]{0,1}[A-Za-z0-9*-_.]{1,}?["]{0,1}$'); // validate regex and ensure we don't have quotes or only two -export const validateLabel = (label: string) => { +export const validateK8SName = (label: string) => { const quotesCount = (label.match(/"/g) || []).length; - return (quotesCount == 0 || quotesCount == 2) && k8sLabel.test(label); + return (quotesCount == 0 || quotesCount == 2) && k8sName.test(label); }; diff --git a/web/src/utils/router.ts b/web/src/utils/router.ts index 3ffa65fcb..d34ae4d52 100644 --- a/web/src/utils/router.ts +++ b/web/src/utils/router.ts @@ -69,7 +69,9 @@ export const buildQueryArguments = ( params[QueryArgument.EndTime] = range.to.toString(); } } - params[ColumnsId.flowdir] = reporterToFlowdir[opts.reporter]; + if (opts.reporter !== 'both') { + params[ColumnsId.flowdir] = reporterToFlowdir[opts.reporter]; + } params[QueryArgument.Limit] = opts.limit; params[QueryArgument.Match] = opts.match; return params;