diff --git a/config/sample-config.yaml b/config/sample-config.yaml index 8c0ab9286..80870395c 100644 --- a/config/sample-config.yaml +++ b/config/sample-config.yaml @@ -21,7 +21,6 @@ loki: tenantID: netobserv useMocks: false prometheus: - url: http://prometheus:9090 timeout: 30s metrics: - enabled: false diff --git a/pkg/kubernetes/auth/check_auth.go b/pkg/kubernetes/auth/check_auth.go index 913eb9512..7a8874f5b 100644 --- a/pkg/kubernetes/auth/check_auth.go +++ b/pkg/kubernetes/auth/check_auth.go @@ -21,11 +21,13 @@ const ( AuthHeader = "Authorization" CheckAuthenticated CheckType = "authenticated" CheckAdmin CheckType = "admin" + CheckDenyAll CheckType = "denyAll" CheckNone CheckType = "none" ) type Checker interface { CheckAuth(ctx context.Context, header http.Header) error + CheckAdmin(ctx context.Context, header http.Header) error } func NewChecker(typez CheckType, apiProvider client.APIProvider) (Checker, error) { @@ -36,6 +38,9 @@ func NewChecker(typez CheckType, apiProvider client.APIProvider) (Checker, error return &BearerTokenChecker{apiProvider: apiProvider, predicates: []authPredicate{mustBeAuthenticated}}, nil case CheckAdmin: return &BearerTokenChecker{apiProvider: apiProvider, predicates: []authPredicate{mustBeAuthenticated, mustBeClusterAdmin}}, nil + case CheckDenyAll: + return &DenyAllChecker{}, nil + } return nil, fmt.Errorf("auth checker type unknown: %s. Must be one of %s, %s, %s", typez, CheckAdmin, CheckAuthenticated, CheckNone) } @@ -49,6 +54,25 @@ func (b *NoopChecker) CheckAuth(_ context.Context, _ http.Header) error { return nil } +func (b *NoopChecker) CheckAdmin(_ context.Context, _ http.Header) error { + hlog.Debug("noop auth checker: ignore auth") + return nil +} + +type DenyAllChecker struct { + Checker +} + +func (b *DenyAllChecker) CheckAuth(_ context.Context, _ http.Header) error { + hlog.Debug("deny all auth checker: deny auth") + return errors.New("deny all auth mode selected") +} + +func (b *DenyAllChecker) CheckAdmin(_ context.Context, _ http.Header) error { + hlog.Debug("deny all auth checker: deny auth") + return errors.New("deny all auth mode selected") +} + func getUserToken(header http.Header) (string, error) { authValue := header.Get(AuthHeader) if authValue != "" { @@ -118,3 +142,22 @@ func (c *BearerTokenChecker) CheckAuth(ctx context.Context, header http.Header) hlog.Debug("Checking auth: passed") return nil } + +func (c *BearerTokenChecker) CheckAdmin(ctx context.Context, header http.Header) error { + hlog.Debug("Checking admin user") + token, err := getUserToken(header) + if err != nil { + return err + } + hlog.Debug("Checking admin: token found") + client, err := c.apiProvider() + if err != nil { + return err + } + if err = mustBeClusterAdmin(ctx, client, token); err != nil { + return err + } + + hlog.Debug("Checking admin: passed") + return nil +} diff --git a/pkg/kubernetes/auth/check_auth_test.go b/pkg/kubernetes/auth/check_auth_test.go index 27222ff0c..b51f6ecf1 100644 --- a/pkg/kubernetes/auth/check_auth_test.go +++ b/pkg/kubernetes/auth/check_auth_test.go @@ -52,6 +52,14 @@ func TestCheckAuth_NoAuth(t *testing.T) { // No header => success err = checkerNoop.CheckAuth(context.TODO(), http.Header{}) require.NoError(t, err) + + // Deny All mode + checkerDenyAll := DenyAllChecker{} + + // No header => fail + err = checkerDenyAll.CheckAuth(context.TODO(), http.Header{}) + require.Error(t, err) + require.Equal(t, "deny all auth mode selected", err.Error()) } func TestCheckAuth_NormalUser(t *testing.T) { diff --git a/pkg/server/routes.go b/pkg/server/routes.go index e2066db5f..09a9f46fc 100644 --- a/pkg/server/routes.go +++ b/pkg/server/routes.go @@ -36,12 +36,13 @@ func setupRoutes(ctx context.Context, cfg *config.Config, authChecker auth.Check orig.ServeHTTP(w, r) }) }) + api.HandleFunc("/status", handler.Status) api.HandleFunc("/loki/ready", h.LokiReady()) - api.HandleFunc("/loki/metrics", h.LokiMetrics()) - api.HandleFunc("/loki/buildinfo", h.LokiBuildInfos()) - api.HandleFunc("/loki/config/limits", h.LokiConfig("limits_config")) - api.HandleFunc("/loki/config/ingester/max_chunk_age", h.IngesterMaxChunkAge()) + api.HandleFunc("/loki/metrics", forceCheckAdmin(authChecker, h.LokiMetrics())) + api.HandleFunc("/loki/buildinfo", forceCheckAdmin(authChecker, h.LokiBuildInfos())) + api.HandleFunc("/loki/config/limits", forceCheckAdmin(authChecker, h.LokiConfig("limits_config"))) + api.HandleFunc("/loki/config/ingester/max_chunk_age", forceCheckAdmin(authChecker, h.IngesterMaxChunkAge())) api.HandleFunc("/loki/flow/records", h.GetFlows(ctx)) api.HandleFunc("/loki/flow/metrics", h.GetTopology(ctx)) api.HandleFunc("/loki/export", h.ExportFlows(ctx)) @@ -55,3 +56,17 @@ func setupRoutes(ctx context.Context, cfg *config.Config, authChecker auth.Check r.PathPrefix("/").Handler(http.FileServer(http.Dir("./web/dist/"))) return r } + +func forceCheckAdmin(authChecker auth.Checker, handle func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + if err := authChecker.CheckAdmin(context.TODO(), r.Header); err != nil { + w.WriteHeader(http.StatusUnauthorized) + _, err2 := w.Write([]byte(err.Error())) + if err2 != nil { + logrus.Errorf("Error while responding an error: %v (initial was: %v)", err2, err) + } + return + } + handle(w, r) + } +} diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index 3eba4ee4a..18b2de749 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -513,8 +513,14 @@ func (a *authMock) CheckAuth(ctx context.Context, header http.Header) error { return args.Error(0) } +func (a *authMock) CheckAdmin(ctx context.Context, header http.Header) error { + args := a.Called(ctx, header) + return args.Error(0) +} + func (a *authMock) MockGranted() { a.On("CheckAuth", mock.Anything, mock.Anything).Return(nil) + a.On("CheckAdmin", mock.Anything, mock.Anything).Return(nil) } func prepareServerAssets(t *testing.T) string { diff --git a/web/locales/en/plugin__netobserv-plugin.json b/web/locales/en/plugin__netobserv-plugin.json index 86e1a408c..574cff7ad 100644 --- a/web/locales/en/plugin__netobserv-plugin.json +++ b/web/locales/en/plugin__netobserv-plugin.json @@ -169,6 +169,8 @@ "Configuration limits": "Configuration limits", "Metrics": "Metrics", "You may consider the following changes to avoid this error:": "You may consider the following changes to avoid this error:", + "Add missing metrics to prometheus using FlowMetric API": "Add missing metrics to prometheus using FlowMetric API", + "Enable Loki in FlowCollector API": "Enable Loki in FlowCollector API", "Reduce the Query Options -> limit to reduce the number of results": "Reduce the Query Options -> limit to reduce the number of results", "Increase Loki \"max_entries_limit_per_query\" entry in configuration file": "Increase Loki \"max_entries_limit_per_query\" entry in configuration file", "Add Namespace, Owner or Resource filters (which use indexed fields) to improve the query performance": "Add Namespace, Owner or Resource filters (which use indexed fields) to improve the query performance", @@ -180,9 +182,16 @@ "This error is generally seen when cluster admin groups are not properly configured.": "This error is generally seen when cluster admin groups are not properly configured.", "Click the link below for more help.": "Click the link below for more help.", "More information": "More information", - "Loki '/ready' endpoint returned the following error": "Loki '/ready' endpoint returned the following error", "Check if Loki is running correctly. '/ready' endpoint should respond \"ready\"": "Check if Loki is running correctly. '/ready' endpoint should respond \"ready\"", - "Configuring Grafana Loki": "Configuring Grafana Loki", + "Check your connectivity with cluster / console plugin pod": "Check your connectivity with cluster / console plugin pod", + "Check current user permissions": "Check current user permissions", + "For LokiStack, your user must either:": "For LokiStack, your user must either:", + "have the 'netobserv-reader' cluster role, which allows multi-tenancy": "have the 'netobserv-reader' cluster role, which allows multi-tenancy", + "or be in the 'cluster-admin' group (not the same as the 'cluster-admin' role)": "or be in the 'cluster-admin' group (not the same as the 'cluster-admin' role)", + "or LokiStack spec.tenants.openshift.adminGroups must be configured with a group this user belongs to": "or LokiStack spec.tenants.openshift.adminGroups must be configured with a group this user belongs to", + "For other configurations, refer to FlowCollector spec.loki.manual.authToken": "For other configurations, refer to FlowCollector spec.loki.manual.authToken", + "Configuring the Loki Operator": "Configuring the Loki Operator", + "Configuring Grafana Loki (community)": "Configuring Grafana Loki (community)", "Show metrics": "Show metrics", "Show build info": "Show build info", "Show configuration limits": "Show configuration limits", @@ -245,7 +254,6 @@ "Show total": "Show total", "Show total dropped": "Show total dropped", "Show overall": "Show overall", - "Unable to get overview": "Unable to get overview", "Graph type": "Graph type", "Donut": "Donut", "Bars": "Bars", @@ -284,8 +292,8 @@ "More info": "More info", "Raw": "Raw", "JSON": "JSON", + "Unable to get {{item}}": "Unable to get {{item}}", "Kind not managed": "Kind not managed", - "Unable to get flows": "Unable to get flows", "Step into this {{name}}": "Step into this {{name}}", "Filter by source or destination {{name}}": "Filter by source or destination {{name}}", "Filter by {{name}}": "Filter by {{name}}", @@ -310,8 +318,10 @@ "Cluster name": "Cluster name", "Edge": "Edge", "Drops": "Drops", - "Unable to get topology": "Unable to get topology", "Query is slow": "Query is slow", + "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": "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", + "Add Namespace, Owner or Resource filters (which use indexed fields), or decrease limit / range, to improve the query performance": "Add Namespace, Owner or Resource filters (which use indexed fields), or decrease limit / range, to improve the query performance", + "Add more filters or decrease limit / range to improve the query performance": "Add more filters or decrease limit / range to improve the query performance", "Overview": "Overview", "Traffic flows": "Traffic flows", "Topology": "Topology", @@ -326,9 +336,6 @@ "More options": "More options", "Time range": "Time range", "Refresh interval": "Refresh interval", - "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": "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", - "Add Namespace, Owner or Resource filters (which use indexed fields), or decrease limit / range, to improve the query performance": "Add Namespace, Owner or Resource filters (which use indexed fields), or decrease limit / range, to improve the query performance", - "Add more filters or decrease limit / range to improve the query performance": "Add more filters or decrease limit / range to improve the query performance", "Network Traffic": "Network Traffic", "Hide histogram": "Hide histogram", "Show histogram": "Show histogram", diff --git a/web/src/components/messages/loki-error.css b/web/src/components/messages/error.css similarity index 65% rename from web/src/components/messages/loki-error.css rename to web/src/components/messages/error.css index 3ab1f5ee7..465c6a454 100644 --- a/web/src/components/messages/loki-error.css +++ b/web/src/components/messages/error.css @@ -1,4 +1,4 @@ -#loki-error-container { +#netobserv-error-container { overflow: auto; height: 100%; } @@ -10,8 +10,8 @@ justify-content: center; } -.loki-error-icon, -.loki-error-message { +.netobserv-error-icon, +.netobserv-error-message { color: #A30000 !important; margin-bottom: 1em; } @@ -22,5 +22,10 @@ } .error-text-content>blockquote { - width: 50%; -} \ No newline at end of file + width: 60%; +} + +.error-text-content ul { + margin-top: 0.5em; + text-align: initial; +} diff --git a/web/src/components/messages/loki-error.tsx b/web/src/components/messages/error.tsx similarity index 55% rename from web/src/components/messages/loki-error.tsx rename to web/src/components/messages/error.tsx index e1ed03615..7c0f84f1b 100644 --- a/web/src/components/messages/loki-error.tsx +++ b/web/src/components/messages/error.tsx @@ -10,17 +10,18 @@ import { Spinner, Text, TextContent, + TextList, + TextListItem, TextVariants, Title } from '@patternfly/react-core'; import { ExclamationCircleIcon, ExternalLinkSquareAltIcon } from '@patternfly/react-icons'; -import _ from 'lodash'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { getBuildInfo, getLimits, getLokiMetrics, getLokiReady } from '../../api/routes'; -import { getHTTPErrorDetails } from '../../utils/errors'; -import './loki-error.css'; +import { getHTTPErrorDetails, getPromUnsupportedError, isPromUnsupportedError } from '../../utils/errors'; +import './error.css'; export type Size = 's' | 'm' | 'l'; @@ -34,12 +35,13 @@ enum LokiInfo { type Props = { title: string; error: string; + isLokiRelated: boolean; }; -export const LokiError: React.FC = ({ title, error }) => { +export const Error: React.FC = ({ title, error, isLokiRelated }) => { const { t } = useTranslation('plugin__netobserv-plugin'); - const [loading, setLoading] = React.useState(true); - const [ready, setReady] = React.useState(); + const [loading, setLoading] = React.useState(isLokiRelated); + const [ready, setReady] = React.useState(); const [infoName, setInfoName] = React.useState(); const [info, setInfo] = React.useState(); @@ -92,33 +94,49 @@ export const LokiError: React.FC = ({ title, error }) => { [t] ); + const getDisplayError = React.useCallback(() => { + return isPromUnsupportedError(error) ? getPromUnsupportedError(error) : error; + }, [error]); + React.useEffect(() => { - getLokiReady() - .then(() => { - setReady(''); - }) - .catch(err => { - setReady(getHTTPErrorDetails(err)); - }) - .finally(() => { - setLoading(false); - }); - }, []); + //jest crashing on getLokiReady not defined so we need to ensure the function is defined here + if (getLokiReady && isLokiRelated) { + getLokiReady() + .then(() => { + setReady(true); + }) + .catch(err => { + console.error(getHTTPErrorDetails(err)); + setReady(false); + }) + .finally(() => { + setLoading(false); + }); + } + }, [isLokiRelated]); return ( -
+
- + {title} - - {error} + + {getDisplayError()} { {t('You may consider the following changes to avoid this error:')} + {error.includes('promUnsupported') && ( + <> + + {t('Add missing metrics to prometheus using FlowMetric API')} + + {t('Enable Loki in FlowCollector API')} + + )} {error.includes('max entries limit') && ( <> @@ -190,55 +208,105 @@ export const LokiError: React.FC = ({ title, error }) => { )} - {!_.isEmpty(ready) && ( + {ready === false && ( <> - {t(`Loki '/ready' endpoint returned the following error`)} - - {ready} + + {t(`Check if Loki is running correctly. '/ready' endpoint should respond "ready"`)} )} - {!_.isEmpty(ready) && ( + {error.includes('Network Error') && ( - {t(`Check if Loki is running correctly. '/ready' endpoint should respond "ready"`)} + {t(`Check your connectivity with cluster / console plugin pod`)} )} + {(error.includes('status code 401') || error.includes('status code 403')) && ( + <> + {t(`Check current user permissions`)} + + {t(`For LokiStack, your user must either:`)} + + + {t(`have the 'netobserv-reader' cluster role, which allows multi-tenancy`)} + + + {t(`or be in the 'cluster-admin' group (not the same as the 'cluster-admin' role)`)} + + + {t( + `or LokiStack spec.tenants.openshift.adminGroups must be configured with a group this user belongs to` + )} + + + + + {t(`For other configurations, refer to FlowCollector spec.loki.manual.authToken`)} + + + )} } - - - - - - - + {isLokiRelated && ( + <> + + + + )} + {isLokiRelated && ( + + + + + + + )} {loading ? ( @@ -264,4 +332,4 @@ export const LokiError: React.FC = ({ title, error }) => { ); }; -export default LokiError; +export default Error; diff --git a/web/src/components/messages/prometheus-unsupported.tsx b/web/src/components/messages/prometheus-unsupported.tsx deleted file mode 100644 index 33949d2ff..000000000 --- a/web/src/components/messages/prometheus-unsupported.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { EmptyState, EmptyStateBody, EmptyStateIcon, Text, TextVariants, Title } from '@patternfly/react-core'; -import { InfoAltIcon } from '@patternfly/react-icons'; -import * as React from 'react'; - -type Props = { - title: string; - error: string; -}; - -export const PrometheusUnsupported: React.FC = ({ title, error }) => { - return ( -
- - - - {title} - - - {error} - - -
- ); -}; diff --git a/web/src/components/netflow-overview/__tests__/netflow-overview.spec.tsx b/web/src/components/netflow-overview/__tests__/netflow-overview.spec.tsx index 2473cfce7..0bbfed724 100644 --- a/web/src/components/netflow-overview/__tests__/netflow-overview.spec.tsx +++ b/web/src/components/netflow-overview/__tests__/netflow-overview.spec.tsx @@ -2,7 +2,6 @@ import { mount, shallow } from 'enzyme'; import * as React from 'react'; import { EmptyState } from '@patternfly/react-core'; -import LokiError from '../../../components/messages/loki-error'; import { metrics, droppedMetrics } from '../../../components/__tests-data__/metrics'; import { RecordType } from '../../../model/flow-query'; @@ -15,7 +14,6 @@ describe('', () => { const props: NetflowOverviewProps = { limit: 5, panels: ShuffledDefaultPanels, - error: undefined as string | undefined, loading: false, recordType: 'flowLog' as RecordType, metrics: { @@ -29,14 +27,6 @@ describe('', () => { const wrapper = shallow(); expect(wrapper.find(NetflowOverview)).toBeTruthy(); }); - it('should render error', async () => { - const wrapper = shallow(); - wrapper.setProps({ - error: 'couic!' - }); - wrapper.update(); - expect(wrapper.find(LokiError)).toHaveLength(1); - }); it('should render empty states', async () => { const wrapper = mount(); const containerDiv = wrapper.find(EmptyState); diff --git a/web/src/components/netflow-overview/netflow-overview.tsx b/web/src/components/netflow-overview/netflow-overview.tsx index 590e92f55..53ef6f955 100644 --- a/web/src/components/netflow-overview/netflow-overview.tsx +++ b/web/src/components/netflow-overview/netflow-overview.tsx @@ -12,38 +12,35 @@ import { SearchIcon } from '@patternfly/react-icons'; import _ from 'lodash'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import { LOCAL_STORAGE_OVERVIEW_KEBAB_KEY, useLocalStorage } from '../../utils/local-storage-hook'; -import { GenericMetric, isValidTopologyMetrics, NamedMetric, NetflowMetrics, TopologyMetrics } from '../../api/loki'; +import { Field, FlowDirection, getDirectionDisplayString } from '../../api/ipfix'; +import { GenericMetric, NamedMetric, NetflowMetrics, TopologyMetrics, isValidTopologyMetrics } from '../../api/loki'; import { RecordType } from '../../model/flow-query'; import { getStat } from '../../model/metrics'; +import { getDNSErrorDescription, getDNSRcodeDescription } from '../../utils/dns'; +import { getDSCPServiceClassName } from '../../utils/dscp'; +import { LOCAL_STORAGE_OVERVIEW_KEBAB_KEY, useLocalStorage } from '../../utils/local-storage-hook'; import { CUSTOM_PANEL_MATCHER, - getFunctionFromId, - getOverviewPanelInfo, - getRateFunctionFromId, OverviewPanel, OverviewPanelId, OverviewPanelInfo, + getFunctionFromId, + getOverviewPanelInfo, + getRateFunctionFromId, parseCustomMetricId } from '../../utils/overview-panels'; +import { convertRemToPixels } from '../../utils/panel'; +import { formatPort } from '../../utils/port'; +import { usePrevious } from '../../utils/previous-hook'; +import { formatProtocol } from '../../utils/protocol'; import { TruncateLength } from '../dropdowns/truncate-dropdown'; -import LokiError from '../messages/loki-error'; +import { MetricsDonut } from '../metrics/metrics-donut'; import { MetricsGraph } from '../metrics/metrics-graph'; -import { observeDOMRect, toNamedMetric } from '../metrics/metrics-helper'; import { MetricsGraphWithTotal } from '../metrics/metrics-graph-total'; -import { MetricsDonut } from '../metrics/metrics-donut'; +import { observeDOMRect, toNamedMetric } from '../metrics/metrics-helper'; import { NetflowOverviewPanel } from './netflow-overview-panel'; import './netflow-overview.css'; import { PanelKebab, PanelKebabOptions } from './panel-kebab'; -import { usePrevious } from '../../utils/previous-hook'; -import { convertRemToPixels } from '../../utils/panel'; -import { Field, FlowDirection, getDirectionDisplayString } from '../../api/ipfix'; -import { formatPort } from '../../utils/port'; -import { formatProtocol } from '../../utils/protocol'; -import { getDSCPServiceClassName } from '../../utils/dscp'; -import { getDNSRcodeDescription, getDNSErrorDescription } from '../../utils/dns'; -import { getPromUnsupportedError, isPromUnsupportedError } from '../../utils/errors'; -import { PrometheusUnsupported } from '../messages/prometheus-unsupported'; type PanelContent = { calculatedTitle?: string; @@ -59,7 +56,6 @@ export type NetflowOverviewProps = { recordType: RecordType; metrics: NetflowMetrics; loading?: boolean; - error?: string; isDark?: boolean; filterActionLinks: JSX.Element; truncateLength: TruncateLength; @@ -73,7 +69,6 @@ export const NetflowOverview: React.FC = ({ recordType, metrics, loading, - error, isDark, filterActionLinks, truncateLength, @@ -821,56 +816,48 @@ export const NetflowOverview: React.FC = ({ ] ); - if (error) { - if (isPromUnsupportedError(error)) { - return ; - } else { - return ; - } - } else { - return ( -
- {allowFocus && selectedPanel && ( -
- {getPanelView(selectedPanel)} -
- )} + return ( +
+ {allowFocus && selectedPanel && (
- - {panels.map((panel, i) => getPanelView(panel, i))} - + {getPanelView(selectedPanel)}
+ )} +
+ + {panels.map((panel, i) => getPanelView(panel, i))} +
- ); - } +
+ ); }; export default NetflowOverview; diff --git a/web/src/components/netflow-tab.tsx b/web/src/components/netflow-tab.tsx index 4a81df7f7..757df82f5 100644 --- a/web/src/components/netflow-tab.tsx +++ b/web/src/components/netflow-tab.tsx @@ -17,6 +17,7 @@ import { loadConfig } from '../utils/config'; import { findFilter, getFilterDefinitions } from '../utils/filter-definitions'; import { usePrevious } from '../utils/previous-hook'; import NetflowTrafficParent from './netflow-traffic-parent'; +import Error from './messages/error'; type RouteProps = K8sResourceCommon & { spec: { @@ -38,8 +39,11 @@ type HPAProps = K8sResourceCommon & { export const NetflowTab: React.FC = ({ obj }) => { const { t } = useTranslation('plugin__netobserv-plugin'); - const initState = React.useRef>([]); + const initState = React.useRef< + Array<'initDone' | 'configLoading' | 'configLoaded' | 'configLoadError' | 'forcedFiltersLoaded'> + >([]); const [config, setConfig] = React.useState(defaultConfig); + const [error, setError] = React.useState(); const [forcedFilters, setForcedFilters] = React.useState(); const previous = usePrevious({ obj }); @@ -52,8 +56,12 @@ export const NetflowTab: React.FC = ({ obj }) => { if (!initState.current.includes('configLoading')) { initState.current.push('configLoading'); loadConfig().then(v => { - setConfig(v); + setConfig(v.config); initState.current.push('configLoaded'); + if (v.error) { + initState.current.push('configLoadError'); + setError(v.error); + } }); } } @@ -168,7 +176,9 @@ export const NetflowTab: React.FC = ({ obj }) => { } }, [config, obj, previous, t]); - if (!initState.current.includes('forcedFiltersLoaded')) { + if (error) { + return ; + } else if (!initState.current.includes('forcedFiltersLoaded')) { return ( diff --git a/web/src/components/netflow-table/__tests__/netflow-table.spec.tsx b/web/src/components/netflow-table/__tests__/netflow-table.spec.tsx index bf7463b13..95335c662 100644 --- a/web/src/components/netflow-table/__tests__/netflow-table.spec.tsx +++ b/web/src/components/netflow-table/__tests__/netflow-table.spec.tsx @@ -10,7 +10,6 @@ import { ShuffledColumnSample } from '../../__tests-data__/columns'; import { FlowsMock, FlowsSample } from '../../__tests-data__/flows'; import { Size } from '../../dropdowns/table-display-dropdown'; import { ColumnsId } from '../../../utils/columns'; -import { LokiError } from '../../messages/loki-error'; import { dateTimeMSFormatter, getFormattedDate } from '../../../utils/datetime'; const errorStateQuery = `EmptyState[data-test="error-state"]`; @@ -115,14 +114,4 @@ describe('', () => { expect(wrapper.find(noResultsFoundQuery)).toHaveLength(1); expect(wrapper.find(errorStateQuery)).toHaveLength(0); }); - it('should render a spinning slide and then an should show an ErrorState on error', async () => { - const wrapper = shallow(); - expect(wrapper.find(NetflowTable)).toBeTruthy(); - expect(wrapper.find(loadingContentsQuery)).toHaveLength(1); - wrapper.setProps({ - error: 'pum!' - }); - wrapper.update(); - expect(wrapper.find(LokiError)).toHaveLength(1); - }); }); diff --git a/web/src/components/netflow-table/netflow-table.css b/web/src/components/netflow-table/netflow-table.css index 69c6887d6..8b5c19b44 100644 --- a/web/src/components/netflow-table/netflow-table.css +++ b/web/src/components/netflow-table/netflow-table.css @@ -1,3 +1,11 @@ #table-container>.pf-c-table tbody>tr>* { vertical-align: top; +} + +#table-container.dark { + background: #0f1214; +} + +#table-container.light { + background: #f0f0f0; } \ No newline at end of file diff --git a/web/src/components/netflow-table/netflow-table.tsx b/web/src/components/netflow-table/netflow-table.tsx index b5266ff69..8ae7e41bb 100644 --- a/web/src/components/netflow-table/netflow-table.tsx +++ b/web/src/components/netflow-table/netflow-table.tsx @@ -25,7 +25,6 @@ import { LOCAL_STORAGE_SORT_ID_KEY, useLocalStorage } from '../../utils/local-storage-hook'; -import { LokiError } from '../messages/loki-error'; import { convertRemToPixels } from '../../utils/panel'; const NetflowTable: React.FC<{ @@ -39,7 +38,6 @@ const NetflowTable: React.FC<{ size: Size; onSelect: (record?: Record) => void; loading?: boolean; - error?: string; filterActionLinks: JSX.Element; isDark?: boolean; }> = ({ @@ -50,7 +48,6 @@ const NetflowTable: React.FC<{ setColumns, columnSizes, setColumnSizes, - error, loading, size, onSelect, @@ -177,10 +174,13 @@ const NetflowTable: React.FC<{ }, [activeSortDirection, activeSortId, columns, flows]); // sort handler - const onSort = (columnId: ColumnsId, direction: SortByDirection) => { - setActiveSortId(columnId); - setActiveSortDirection(direction); - }; + const onSort = React.useCallback( + (columnId: ColumnsId, direction: SortByDirection) => { + setActiveSortId(columnId); + setActiveSortDirection(direction); + }, + [setActiveSortDirection, setActiveSortId] + ); const getBody = React.useCallback(() => { const rowHeight = getRowHeight(); @@ -231,8 +231,6 @@ const NetflowTable: React.FC<{ if (width === 0) { return null; - } else if (error) { - return ; } else if (_.isEmpty(flows)) { if (loading) { return ( diff --git a/web/src/components/netflow-topology/__tests__/netflow-topology.spec.tsx b/web/src/components/netflow-topology/__tests__/netflow-topology.spec.tsx index 323a58dee..f8493cd9f 100644 --- a/web/src/components/netflow-topology/__tests__/netflow-topology.spec.tsx +++ b/web/src/components/netflow-topology/__tests__/netflow-topology.spec.tsx @@ -9,7 +9,6 @@ import { defaultTimeRange } from '../../../utils/router'; import { NetflowTopology } from '../netflow-topology'; import { TopologyContent } from '../2d/topology-content'; import { dataSample } from '../__tests-data__/metrics'; -import { LokiError } from '../../messages/loki-error'; import { FilterDefinitionSample } from '../../../components/__tests-data__/filters'; describe('', () => { @@ -56,10 +55,4 @@ describe('', () => { const wrapper = shallow(); expect(wrapper.find(Spinner)).toHaveLength(1); }); - - it('should render error', async () => { - mocks.error = 'test error message'; - const wrapper = shallow(); - expect(wrapper.find(LokiError)).toHaveLength(1); - }); }); diff --git a/web/src/components/netflow-topology/netflow-topology.css b/web/src/components/netflow-topology/netflow-topology.css new file mode 100644 index 000000000..3e4ce0ee6 --- /dev/null +++ b/web/src/components/netflow-topology/netflow-topology.css @@ -0,0 +1,7 @@ +#topology-container.dark { + background: #0f1214; +} + +#topology-container.light { + background: #f0f0f0; +} \ No newline at end of file diff --git a/web/src/components/netflow-topology/netflow-topology.tsx b/web/src/components/netflow-topology/netflow-topology.tsx index 7afc5392f..b3e16f110 100644 --- a/web/src/components/netflow-topology/netflow-topology.tsx +++ b/web/src/components/netflow-topology/netflow-topology.tsx @@ -3,27 +3,23 @@ import { Bullseye, Spinner } from '@patternfly/react-core'; import { Visualization, VisualizationProvider } from '@patternfly/react-topology'; import _ from 'lodash'; import * as React from 'react'; -import { useTranslation } from 'react-i18next'; import { TopologyMetrics } from '../../api/loki'; import { Filter, FilterDefinition, Filters } from '../../model/filters'; -import { StatFunction, FlowScope, MetricType } from '../../model/flow-query'; +import { FlowScope, MetricType, StatFunction } from '../../model/flow-query'; import { GraphElementPeer, LayoutName, TopologyOptions } from '../../model/topology'; -import LokiError from '../messages/loki-error'; +import { observeDOMRect } from '../metrics/metrics-helper'; +import { ScopeSlider } from '../scope-slider/scope-slider'; import { SearchEvent, SearchHandle } from '../search/search'; -import { TopologyContent } from './2d/topology-content'; -import ThreeDTopologyContent from './3d/three-d-topology-content'; import componentFactory from './2d/componentFactories/componentFactory'; import stylesComponentFactory from './2d/componentFactories/stylesComponentFactory'; import layoutFactory from './2d/layouts/layoutFactory'; -import { ScopeSlider } from '../scope-slider/scope-slider'; -import { observeDOMRect } from '../metrics/metrics-helper'; -import { getPromUnsupportedError, isPromUnsupportedError } from '../../utils/errors'; -import { PrometheusUnsupported } from '../messages/prometheus-unsupported'; +import { TopologyContent } from './2d/topology-content'; +import ThreeDTopologyContent from './3d/three-d-topology-content'; +import './netflow-topology.css'; export const NetflowTopology: React.FC<{ loading?: boolean; k8sModels: { [key: string]: K8sModel }; - error?: string; metricFunction: StatFunction; metricType: MetricType; metricScope: FlowScope; @@ -44,7 +40,6 @@ export const NetflowTopology: React.FC<{ }> = ({ loading, k8sModels, - error, metricFunction, metricType, metricScope, @@ -63,7 +58,6 @@ export const NetflowTopology: React.FC<{ isDark, allowedScopes }) => { - const { t } = useTranslation('plugin__netobserv-plugin'); const containerRef = React.createRef(); const [containerSize, setContainerSize] = React.useState({ width: 0, height: 0 } as DOMRect); const [controller, setController] = React.useState(); @@ -72,13 +66,7 @@ export const NetflowTopology: React.FC<{ const displayedMetrics = _.isEmpty(metrics) ? droppedMetrics : metrics; const getContent = React.useCallback(() => { - if (error) { - if (isPromUnsupportedError(error)) { - return ; - } else { - return ; - } - } else if (!controller || (_.isEmpty(metrics) && loading)) { + if (!controller || (_.isEmpty(metrics) && loading)) { return ( @@ -141,7 +129,6 @@ export const NetflowTopology: React.FC<{ controller, displayedMetrics, droppedMetrics, - error, filterDefinitions, filters, isDark, @@ -158,8 +145,7 @@ export const NetflowTopology: React.FC<{ selected, setFilters, setMetricScope, - setOptions, - t + setOptions ]); //create controller on startup and register factories diff --git a/web/src/components/netflow-traffic.tsx b/web/src/components/netflow-traffic.tsx index 27b157008..a0bbeac3a 100644 --- a/web/src/components/netflow-traffic.tsx +++ b/web/src/components/netflow-traffic.tsx @@ -82,7 +82,7 @@ import { loadConfig, loadMaxChunkAge } from '../utils/config'; import { ContextSingleton } from '../utils/context'; import { computeStepInterval, getTimeRangeOptions, TimeRange } from '../utils/datetime'; import { formatDuration, getDateMsInSeconds, getDateSInMiliseconds, parseDuration } from '../utils/duration'; -import { getHTTPErrorDetails } from '../utils/errors'; +import { getHTTPErrorDetails, isPromUnsupportedError } from '../utils/errors'; import { exportToPng } from '../utils/export'; import { mergeFlowReporters } from '../utils/flows'; import { useK8sModelsWithColors } from '../utils/k8s-models-hook'; @@ -172,6 +172,7 @@ import FlowsQuerySummary from './query-summary/flows-query-summary'; import MetricsQuerySummary from './query-summary/metrics-query-summary'; import SummaryPanel from './query-summary/summary-panel'; import { SearchComponent, SearchEvent, SearchHandle } from './search/search'; +import Error from './messages/error'; export type ViewId = 'overview' | 'table' | 'topology'; @@ -266,8 +267,10 @@ export const NetflowTraffic: React.FC = ({ forcedFilters, i | 'initDone' | 'configLoading' | 'configLoaded' + | 'configLoadError' | 'maxChunkAgeLoading' | 'maxChunkAgeLoaded' + | 'maxChunkAgeLoadError' | 'forcedFiltersLoaded' | 'urlFiltersPending' > @@ -522,6 +525,9 @@ export const NetflowTraffic: React.FC = ({ forcedFilters, i if (view !== selectedViewId) { setFlows([]); setMetrics(defaultNetflowMetrics); + if (!initState.current.includes('configLoadError') && !initState.current.includes('maxChunkAgeLoadError')) { + setError(undefined); + } } setSelectedViewId(view); }; @@ -620,6 +626,23 @@ export const NetflowTraffic: React.FC = ({ forcedFilters, i [] ); + const slownessReason = React.useCallback((): string => { + if (match === 'any' && hasNonIndexFields(filters.list)) { + return 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' + ); + } + if (match === 'all' && !hasIndexFields(filters.list)) { + return 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' + ); + } + return t('Add more filters or decrease limit / range to improve the query performance'); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [match, filters]); + const fetchTable = React.useCallback( (fq: FlowQuery) => { if (!showHistogram) { @@ -1003,7 +1026,11 @@ export const NetflowTraffic: React.FC = ({ forcedFilters, i // skip tick while forcedFilters & config are not loaded // this check ensure tick will not be called during init // as it's difficult to manage react state changes - if (!initState.current.includes('forcedFiltersLoaded') || !initState.current.includes('configLoaded')) { + if ( + !initState.current.includes('forcedFiltersLoaded') || + !initState.current.includes('configLoaded') || + initState.current.includes('configLoadError') + ) { console.error('tick skipped', initState.current); return; } else if (isTRModalOpen || isOverviewModalOpen || isColModalOpen || isExportModalOpen) { @@ -1131,7 +1158,11 @@ export const NetflowTraffic: React.FC = ({ forcedFilters, i initState.current.push('configLoading'); loadConfig().then(v => { initState.current.push('configLoaded'); - setConfig(v); + setConfig(v.config); + if (v.error) { + initState.current.push('configLoadError'); + setError(v.error); + } }); } } @@ -1141,7 +1172,11 @@ export const NetflowTraffic: React.FC = ({ forcedFilters, i initState.current.push('maxChunkAgeLoading'); loadMaxChunkAge().then(v => { initState.current.push('maxChunkAgeLoaded'); - setMaxChunkAge(v); + setMaxChunkAge(v.duration); + if (v.error && !error) { + initState.current.push('maxChunkAgeLoadError'); + setError(v.error); + } }); } @@ -1574,91 +1609,84 @@ export const NetflowTraffic: React.FC = ({ forcedFilters, i ); }, [getDefaultFilters, t, resetDefaultFilters, filters.list, clearFilters]); - const slownessReason = React.useCallback((): string => { - if (match === 'any' && hasNonIndexFields(filters.list)) { - return 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' - ); - } - if (match === 'all' && !hasIndexFields(filters.list)) { - return 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' - ); - } - return t('Add more filters or decrease limit / range to improve the query performance'); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [match, filters]); - const pageContent = React.useCallback(() => { let content: JSX.Element | null = null; - switch (selectedViewId) { - case 'overview': - content = ( - - ); - break; - case 'table': - content = ( - setColumns(v.concat(columns.filter(col => !col.isSelected)))} - columnSizes={columnSizes} - setColumnSizes={setColumnSizes} - filterActionLinks={filterLinks()} - isDark={isDarkTheme} - /> - ); - break; - case 'topology': - content = ( - - ); - break; - default: - content = null; - break; + + if (error) { + content = ( + + ); + } else { + switch (selectedViewId) { + case 'overview': + content = ( + + ); + break; + case 'table': + content = ( + setColumns(v.concat(columns.filter(col => !col.isSelected)))} + columnSizes={columnSizes} + setColumnSizes={setColumnSizes} + filterActionLinks={filterLinks()} + isDark={isDarkTheme} + /> + ); + break; + case 'topology': + content = ( + + ); + break; + default: + content = null; + break; + } } return ( @@ -1744,6 +1772,7 @@ export const NetflowTraffic: React.FC = ({ forcedFilters, i size, slownessReason, stats, + t, topologyMetricFunction, topologyMetricType, topologyOptions, diff --git a/web/src/model/config.ts b/web/src/model/config.ts index f6ba8bc7e..948c387aa 100644 --- a/web/src/model/config.ts +++ b/web/src/model/config.ts @@ -52,6 +52,6 @@ export const defaultConfig: Config = { merge: false }, fields: [], - dataSources: [], + dataSources: ['loki', 'prom'], promLabels: [] }; diff --git a/web/src/utils/config.ts b/web/src/utils/config.ts index 5fe44462f..72bb51977 100644 --- a/web/src/utils/config.ts +++ b/web/src/utils/config.ts @@ -6,20 +6,25 @@ import { parseDuration } from './duration'; export let config = defaultConfig; export const loadConfig = async () => { + let error = null; try { config = await getConfig(); } catch (err) { - console.log(getHTTPErrorDetails(err)); + error = getHTTPErrorDetails(err); + console.log(error); } - return config; + return { config, error }; }; export const loadMaxChunkAge = async () => { + let duration = NaN; + let error = null; try { const maxChunkAgeStr = await getIngesterMaxChunkAge(); - return parseDuration(maxChunkAgeStr); + duration = parseDuration(maxChunkAgeStr); } catch (err) { - console.log(getHTTPErrorDetails(err)); + error = getHTTPErrorDetails(err); + console.log(error); } - return NaN; + return { duration, error }; };