From 565ac619d68739a00a8f194398b799262db2e4a3 Mon Sep 17 00:00:00 2001 From: Julien Pinsonneau Date: Thu, 5 Sep 2024 12:54:31 +0200 Subject: [PATCH 1/9] improve error messages + small fixes --- pkg/handler/clients.go | 20 ++- pkg/handler/loki.go | 12 +- pkg/handler/resources.go | 62 ++++--- pkg/handler/resources_test.go | 2 +- pkg/handler/status.go | 72 ++++++++- pkg/server/routes.go | 12 +- pkg/server/server_test.go | 14 +- web/locales/en/plugin__netobserv-plugin.json | 20 ++- web/moduleMapper/dummy.tsx | 4 +- web/src/api/loki.ts | 9 ++ web/src/api/routes.ts | 33 ++-- .../drawer/netflow-traffic-drawer.tsx | 46 +++--- web/src/components/messages/empty.css | 24 +++ web/src/components/messages/empty.tsx | 95 +++++++++++ web/src/components/messages/error.tsx | 153 +++++++++--------- .../components/messages/secondary-action.tsx | 87 ++++++++++ web/src/components/messages/status-texts.tsx | 51 ++++++ web/src/components/netflow-traffic-tab.tsx | 23 ++- web/src/components/netflow-traffic.css | 4 +- web/src/components/netflow-traffic.tsx | 2 +- web/src/components/slider/scope-slider.tsx | 2 +- .../__tests__/netflow-overview.spec.tsx | 1 - .../netflow-overview/netflow-overview.tsx | 74 ++++----- .../__tests__/netflow-table.spec.tsx | 1 - .../tabs/netflow-table/netflow-table.tsx | 32 ++-- .../netflow-topology/2d/topology-content.tsx | 18 ++- .../netflow-topology/netflow-topology.tsx | 6 +- web/src/model/netflow-traffic.ts | 7 + web/src/utils/context.ts | 28 +++- web/src/utils/filter-options.ts | 29 ++-- 30 files changed, 681 insertions(+), 262 deletions(-) create mode 100644 web/src/components/messages/empty.css create mode 100644 web/src/components/messages/empty.tsx create mode 100644 web/src/components/messages/secondary-action.tsx create mode 100644 web/src/components/messages/status-texts.tsx diff --git a/pkg/handler/clients.go b/pkg/handler/clients.go index abb12134e..1d5684480 100644 --- a/pkg/handler/clients.go +++ b/pkg/handler/clients.go @@ -23,14 +23,16 @@ type clients struct { } func newClients(cfg *config.Config, requestHeader http.Header, useLokiStatus bool, namespace string) (clients, error) { - var lokiClient httpclient.Caller + lokiClients := newLokiClients(cfg, requestHeader, useLokiStatus) + promClients, err := newPromClients(cfg, requestHeader, namespace) + return clients{loki: lokiClients.loki, promAdmin: promClients.promAdmin, promDev: promClients.promDev}, err +} + +func newPromClients(cfg *config.Config, requestHeader http.Header, namespace string) (clients, error) { var promAdminClient api.Client var promDevClient api.Client var err error - if cfg.IsLokiEnabled() { - lokiClient = newLokiClient(&cfg.Loki, requestHeader, useLokiStatus) - } if cfg.IsPromEnabled() { promAdminClient, err = prometheus.NewAdminClient(&cfg.Prometheus, requestHeader) if err != nil { @@ -41,7 +43,15 @@ func newClients(cfg *config.Config, requestHeader http.Header, useLokiStatus boo return clients{}, err } } - return clients{loki: lokiClient, promAdmin: promAdminClient, promDev: promDevClient}, err + return clients{promAdmin: promAdminClient, promDev: promDevClient}, err +} + +func newLokiClients(cfg *config.Config, requestHeader http.Header, useLokiStatus bool) clients { + var lokiClient httpclient.Caller + if cfg.IsLokiEnabled() { + lokiClient = newLokiClient(&cfg.Loki, requestHeader, useLokiStatus) + } + return clients{loki: lokiClient} } type datasourceError struct { diff --git a/pkg/handler/loki.go b/pkg/handler/loki.go index 767970721..c99997b85 100644 --- a/pkg/handler/loki.go +++ b/pkg/handler/loki.go @@ -191,21 +191,23 @@ func getLokiNamesForPrefix(cfg *config.Loki, lokiClient httpclient.Caller, filts return values, http.StatusOK, nil } +func (h *Handlers) getLokiStatus(r *http.Request) ([]byte, int, error) { + lokiClient := newLokiClient(&h.Cfg.Loki, r.Header, true) + baseURL := strings.TrimRight(h.Cfg.Loki.GetStatusURL(), "/") + return executeLokiQuery(fmt.Sprintf("%s/%s", baseURL, "ready"), lokiClient) +} + func (h *Handlers) LokiReady() func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { if !h.Cfg.IsLokiEnabled() { writeError(w, http.StatusBadRequest, "Loki is disabled") return } - lokiClient := newLokiClient(&h.Cfg.Loki, r.Header, true) - baseURL := strings.TrimRight(h.Cfg.Loki.GetStatusURL(), "/") - - resp, code, err := executeLokiQuery(fmt.Sprintf("%s/%s", baseURL, "ready"), lokiClient) + resp, code, err := h.getLokiStatus(r) if err != nil { writeError(w, code, err.Error()) return } - status := string(resp) if strings.Contains(status, "ready") { code = http.StatusOK diff --git a/pkg/handler/resources.go b/pkg/handler/resources.go index 2e70f2e73..7cb25c000 100644 --- a/pkg/handler/resources.go +++ b/pkg/handler/resources.go @@ -19,6 +19,7 @@ func (h *Handlers) GetClusters(ctx context.Context) func(w http.ResponseWriter, return func(w http.ResponseWriter, r *http.Request) { params := r.URL.Query() namespace := params.Get(namespaceKey) + isDev := namespace != "" clients, err := newClients(h.Cfg, r.Header, false, namespace) if err != nil { @@ -32,9 +33,9 @@ func (h *Handlers) GetClusters(ctx context.Context) func(w http.ResponseWriter, }() // Fetch and merge values for K8S_ClusterName - values, code, err := h.getLabelValues(ctx, clients, fields.Cluster) + values, code, err := h.getLabelValues(ctx, clients, fields.Cluster, isDev) if err != nil { - writeError(w, code, "Error while fetching label cluster values from Loki: "+err.Error()) + writeError(w, code, "Error while fetching label cluster values: "+err.Error()) return } @@ -47,6 +48,7 @@ func (h *Handlers) GetZones(ctx context.Context) func(w http.ResponseWriter, r * return func(w http.ResponseWriter, r *http.Request) { params := r.URL.Query() namespace := params.Get(namespaceKey) + isDev := namespace != "" clients, err := newClients(h.Cfg, r.Header, false, namespace) if err != nil { @@ -63,16 +65,16 @@ func (h *Handlers) GetZones(ctx context.Context) func(w http.ResponseWriter, r * values := []string{} // Fetch and merge values for SrcK8S_Zone and DstK8S_Zone - values1, code, err := h.getLabelValues(ctx, clients, fields.SrcZone) + values1, code, err := h.getLabelValues(ctx, clients, fields.SrcZone, isDev) if err != nil { - writeError(w, code, "Error while fetching label source zone values from Loki: "+err.Error()) + writeError(w, code, "Error while fetching label source zone values: "+err.Error()) return } values = append(values, values1...) - values2, code, err := h.getLabelValues(ctx, clients, fields.DstZone) + values2, code, err := h.getLabelValues(ctx, clients, fields.DstZone, isDev) if err != nil { - writeError(w, code, "Error while fetching label destination zone values from Loki: "+err.Error()) + writeError(w, code, "Error while fetching label destination zone values: "+err.Error()) return } values = append(values, values2...) @@ -82,10 +84,31 @@ func (h *Handlers) GetZones(ctx context.Context) func(w http.ResponseWriter, r * } } +func (h *Handlers) getNamespacesValues(ctx context.Context, clients clients, isDev bool) ([]string, int, error) { + // Initialize values explicitly to avoid null json when empty + values := []string{} + + // Fetch and merge values for SrcK8S_Namespace and DstK8S_Namespace + values1, code, err := h.getLabelValues(ctx, clients, fields.SrcNamespace, isDev) + if err != nil { + return []string{}, code, err + } + values = append(values, values1...) + + values2, code, err := h.getLabelValues(ctx, clients, fields.DstNamespace, isDev) + if err != nil { + return []string{}, code, err + } + values = append(values, values2...) + + return values, http.StatusOK, nil +} + func (h *Handlers) GetNamespaces(ctx context.Context) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { params := r.URL.Query() namespace := params.Get(namespaceKey) + isDev := namespace != "" clients, err := newClients(h.Cfg, r.Header, false, namespace) if err != nil { @@ -98,34 +121,23 @@ func (h *Handlers) GetNamespaces(ctx context.Context) func(w http.ResponseWriter metrics.ObserveHTTPCall("GetNamespaces", code, startTime) }() - // Initialize values explicitly to avoid null json when empty - values := []string{} - - // Fetch and merge values for SrcK8S_Namespace and DstK8S_Namespace - values1, code, err := h.getLabelValues(ctx, clients, fields.SrcNamespace) + values, code, err := h.getNamespacesValues(ctx, clients, isDev) if err != nil { - writeError(w, code, "Error while fetching label source namespace values from Loki: "+err.Error()) + writeError(w, code, "Error while fetching label namespace values: "+err.Error()) return } - values = append(values, values1...) - - values2, code, err := h.getLabelValues(ctx, clients, fields.DstNamespace) - if err != nil { - writeError(w, code, "Error while fetching label destination namespace values from Loki: "+err.Error()) - return - } - values = append(values, values2...) - - code = http.StatusOK writeJSON(w, code, utils.NonEmpty(utils.Dedup(values))) } } -func (h *Handlers) getLabelValues(ctx context.Context, cl clients, label string) ([]string, int, error) { +func (h *Handlers) getLabelValues(ctx context.Context, cl clients, label string, isDev bool) ([]string, int, error) { if h.PromInventory != nil && h.PromInventory.LabelExists(label) { - return prometheus.GetLabelValues(ctx, cl.promAdmin, label, nil) + client := cl.getPromClient(isDev) + if client != nil { + return prometheus.GetLabelValues(ctx, client, label, nil) + } } - if h.Cfg.IsLokiEnabled() { + if cl.loki != nil { return getLokiLabelValues(h.Cfg.Loki.URL, cl.loki, label) } // Loki disabled AND label not managed in metrics => send an error diff --git a/pkg/handler/resources_test.go b/pkg/handler/resources_test.go index 92eb1a2a6..9ac43ee5b 100644 --- a/pkg/handler/resources_test.go +++ b/pkg/handler/resources_test.go @@ -72,7 +72,7 @@ func TestGetLabelValues(t *testing.T) { ) }) cl := clients{loki: lokiClientMock} - _, _, _ = h.getLabelValues(context.Background(), cl, "DstK8S_Namespace") + _, _, _ = h.getLabelValues(context.Background(), cl, "DstK8S_Namespace", false) lokiClientMock.AssertNumberOfCalls(t, "Get", 1) } diff --git a/pkg/handler/status.go b/pkg/handler/status.go index 1176a3edf..90db9fff2 100644 --- a/pkg/handler/status.go +++ b/pkg/handler/status.go @@ -1,14 +1,74 @@ package handler import ( + "context" "net/http" - - "github.com/sirupsen/logrus" + "strings" ) -func Status(w http.ResponseWriter, _ *http.Request) { - _, err := w.Write([]byte("OK")) - if err != nil { - logrus.Errorf("could not write response: %v", err) +type Status struct { + IsAllowProm bool `yaml:"isAllowProm" json:"isAllowProm"` + PromNamespacesCount int `yaml:"promNamespacesCount" json:"promNamespacesCount"` + IsAllowLoki bool `yaml:"isAllowLoki" json:"isAllowLoki"` + LokiNamespacesCount int `yaml:"lokiNamespacesCount" json:"lokiNamespacesCount"` + IsLokiReady bool `yaml:"isLokiReady" json:"isLokiReady"` + IsConsistent bool `yaml:"isConsistent" json:"isConsistent"` +} + +func (h *Handlers) Status(ctx context.Context) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + params := r.URL.Query() + namespace := params.Get(namespaceKey) + isDev := namespace != "" + + status := Status{ + IsAllowProm: h.Cfg.IsPromEnabled(), + IsAllowLoki: h.Cfg.IsLokiEnabled(), + } + + if status.IsAllowProm { + promClients, err := newPromClients(h.Cfg, r.Header, namespace) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + // get namespaces using Prom + promNamespaces, code, err := h.getNamespacesValues(ctx, promClients, isDev) + if err != nil { + writeError(w, code, "Error while fetching label namespace values from Prometheus: "+err.Error()) + return + } + status.PromNamespacesCount = len(promNamespaces) + } + + if status.IsAllowLoki { + resp, code, err := h.getLokiStatus(r) + if err != nil { + writeError(w, code, err.Error()) + return + } + lokiStatus := string(resp) + if strings.Contains(lokiStatus, "ready") { + status.IsLokiReady = true + } else { + status.IsLokiReady = false + } + + lokiClients := newLokiClients(h.Cfg, r.Header, false) + // get namespaces using Loki + lokiNamespaces, code, err := h.getNamespacesValues(ctx, lokiClients, isDev) + if err != nil { + writeError(w, code, "Error while fetching label namespace values from Loki: "+err.Error()) + return + } + status.LokiNamespacesCount = len(lokiNamespaces) + } + // consistent if both datasources are enabled and counts are equal + if status.IsAllowLoki && status.IsAllowProm { + status.IsConsistent = status.PromNamespacesCount == status.LokiNamespacesCount + } else { + status.IsConsistent = true + } + writeJSON(w, http.StatusOK, status) } } diff --git a/pkg/server/routes.go b/pkg/server/routes.go index a2f35a045..d4a3de92e 100644 --- a/pkg/server/routes.go +++ b/pkg/server/routes.go @@ -37,21 +37,27 @@ func setupRoutes(ctx context.Context, cfg *config.Config, authChecker auth.Check }) }) - api.HandleFunc("/status", handler.Status) + // Server status + api.HandleFunc("/status", h.Status(ctx)) + + // Loki endpoints api.HandleFunc("/loki/ready", h.LokiReady()) api.HandleFunc("/loki/metrics", forceCheckAdmin(authChecker, h.LokiMetrics())) api.HandleFunc("/loki/buildinfo", forceCheckAdmin(authChecker, h.LokiBuildInfos())) api.HandleFunc("/loki/config/limits", forceCheckAdmin(authChecker, h.LokiLimits())) api.HandleFunc("/loki/flow/records", h.GetFlows(ctx)) - api.HandleFunc("/loki/flow/metrics", h.GetTopology(ctx)) api.HandleFunc("/loki/export", h.ExportFlows(ctx)) + + // Common endpoints + api.HandleFunc("/flow/metrics", h.GetTopology(ctx)) api.HandleFunc("/resources/clusters", h.GetClusters(ctx)) api.HandleFunc("/resources/zones", h.GetZones(ctx)) api.HandleFunc("/resources/namespaces", h.GetNamespaces(ctx)) api.HandleFunc("/resources/namespace/{namespace}/kind/{kind}/names", h.GetNames(ctx)) api.HandleFunc("/resources/kind/{kind}/names", h.GetNames(ctx)) - api.HandleFunc("/frontend-config", h.GetFrontendConfig()) + // Frontend files + api.HandleFunc("/frontend-config", h.GetFrontendConfig()) r.PathPrefix("/").Handler(http.FileServer(http.Dir("./web/dist/"))) return r } diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index 06d7ce6d3..457d83da0 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -56,8 +56,9 @@ func TestServerRunning(t *testing.T) { go func() { Start(context.TODO(), &config.Config{ - Loki: config.Loki{URL: "http://localhost:3100"}, - Server: config.Server{Port: testPort}, + Loki: config.Loki{URL: ""}, + Prometheus: config.Prometheus{URL: ""}, + Server: config.Server{Port: testPort}, }, &authM) }() @@ -172,7 +173,8 @@ func TestSecureComm(t *testing.T) { defer os.Remove(testClientKeyFile) conf := &config.Config{ - Loki: config.Loki{URL: "http://localhost:3100"}, + Loki: config.Loki{URL: ""}, + Prometheus: config.Prometheus{URL: ""}, Server: config.Server{ Port: testPort, CertPath: testServerCertFile, @@ -349,8 +351,8 @@ func TestLokiConfigurationForTopology(t *testing.T) { backendSvc := httptest.NewServer(backendRoutes) defer backendSvc.Close() - // WHEN the Loki flows endpoint is queried in the backend - resp, err := backendSvc.Client().Get(backendSvc.URL + "/api/loki/flow/metrics?aggregateBy=resource") + // WHEN the flows endpoint is queried in the backend + resp, err := backendSvc.Client().Get(backendSvc.URL + "/api/flow/metrics?aggregateBy=resource") require.NoError(t, err) // THEN the query has been properly forwarded to Loki @@ -408,7 +410,7 @@ func TestLokiConfigurationForTableHistogram(t *testing.T) { defer backendSvc.Close() // WHEN the Loki flows endpoint is queried in the backend using flow count type - resp, err := backendSvc.Client().Get(backendSvc.URL + "/api/loki/flow/metrics?type=Flows&function=count&aggregateBy=resource") + resp, err := backendSvc.Client().Get(backendSvc.URL + "/api/flow/metrics?type=Flows&function=count&aggregateBy=resource") require.NoError(t, err) // THEN the query has been properly forwarded to Loki diff --git a/web/locales/en/plugin__netobserv-plugin.json b/web/locales/en/plugin__netobserv-plugin.json index 2d2ac0d8d..a88ddb093 100644 --- a/web/locales/en/plugin__netobserv-plugin.json +++ b/web/locales/en/plugin__netobserv-plugin.json @@ -33,8 +33,6 @@ "Details": "Details", "Metrics": "Metrics", "Drops": "Drops", - "Reset defaults": "Reset defaults", - "Clear all": "Clear all", "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", @@ -182,6 +180,9 @@ "Previous tip": "Previous tip", "Next tip": "Next tip", "Close tips": "Close tips", + "No results found": "No results found", + "Clear or reset filters and try again.": "Clear or reset filters and try again.", + "Check for errors in health dashboard. Status endpoint is returning: {{statusError}}": "Check for errors in health dashboard. Status endpoint is returning: {{statusError}}", "Build info": "Build info", "Configuration limits": "Configuration limits", "You may consider the following changes to avoid this error:": "You may consider the following changes to avoid this error:", @@ -198,7 +199,6 @@ "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", - "Check if Loki is running correctly. '/ready' endpoint should respond \"ready\"": "Check if Loki is running correctly. '/ready' endpoint should respond \"ready\"", "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:", @@ -208,10 +208,18 @@ "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 FlowCollector CR": "Show FlowCollector CR", + "Show health dashboard": "Show health dashboard", + "Clear all filters": "Clear all filters", + "Reset defaults filters": "Reset defaults filters", "Show metrics": "Show metrics", "Show build info": "Show build info", "Show configuration limits": "Show configuration limits", - "Show health dashboard": "Show health dashboard", + "Check if Loki is running correctly. '/ready' endpoint should respond \"ready\"": "Check if Loki is running correctly. '/ready' endpoint should respond \"ready\"", + "Can't find any namespace label in your Loki storage.": "Can't find any namespace label in your Loki storage.", + "Can't find any namespace label in your Prometheus storage.": "Can't find any namespace label in your Prometheus storage.", + "If this is the first time you run the operator, check FlowCollector status and health dashboard to ensure there is no error and flows are ingested. This can take some time.": "If this is the first time you run the operator, check FlowCollector status and health dashboard to ensure there is no error and flows are ingested. This can take some time.", + "Loki and Prom storages are not consistent. Check health dashboard for errors.": "Loki and Prom storages are not consistent. Check health dashboard for errors.", "Zoom in / zoom out the histogram displayed time range. The selected time range in the top right corner will adapt accordingly. You can also use the plus or minus buttons while the histogram is focused.": "Zoom in / zoom out the histogram displayed time range. The selected time range in the top right corner will adapt accordingly. You can also use the plus or minus buttons while the histogram is focused.", "Move the selected range to filter the table below as time based pagination. You can also use the page up or down buttons while the histogram is focused.": "Move the selected range to filter the table below as time based pagination. You can also use the page up or down buttons while the histogram is focused.", "Shift the histogram displayed time range. The selected time range in the top right corner will adapt accordingly. You can also use the arrow left or right buttons while the histogram is focused.": "Shift the histogram displayed time range. The selected time range in the top right corner will adapt accordingly. You can also use the arrow left or right buttons while the histogram is focused.", @@ -323,8 +331,6 @@ "Find in view": "Find in view", "Show all graphs": "Show all graphs", "Focus on this graph": "Focus on this graph", - "No results found": "No results found", - "Clear or reset filters and try again.": "Clear or reset filters and try again.", "Show total": "Show total", "Show total dropped": "Show total dropped", "Show overall": "Show overall", @@ -379,6 +385,8 @@ "group filter": "group filter", "filter": "filter", "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", diff --git a/web/moduleMapper/dummy.tsx b/web/moduleMapper/dummy.tsx index d44bb6719..8d3b2c115 100644 --- a/web/moduleMapper/dummy.tsx +++ b/web/moduleMapper/dummy.tsx @@ -20,7 +20,9 @@ export function isModelFeatureFlag(e: never) { export function useResolvedExtensions(isModelFeatureFlag: boolean) { return [ [{ - flags: "dummy", + flags: { + required: ["dummy"], + }, model: "", }], undefined, undefined]; diff --git a/web/src/api/loki.ts b/web/src/api/loki.ts index ddf5b8901..9c6749520 100644 --- a/web/src/api/loki.ts +++ b/web/src/api/loki.ts @@ -197,3 +197,12 @@ export const isValidTopologyMetrics = (metric: any): metric is TopologyMetrics = typeof metric.scope === 'string' ); }; + +export interface Status { + isAllowProm: boolean; + promNamespacesCount: number; + isAllowLoki: boolean; + lokiNamespacesCount: number; + isLokiReady: boolean; + isConsistent: boolean; +} diff --git a/web/src/api/routes.ts b/web/src/api/routes.ts index 8227ce61c..85072a600 100644 --- a/web/src/api/routes.ts +++ b/web/src/api/routes.ts @@ -15,6 +15,7 @@ import { RawTopologyMetrics, RecordsResult, Stats, + Status, StreamResult } from './loki'; @@ -54,8 +55,9 @@ export const getExportFlowsURL = (params: FlowQuery, columns?: string[]): string return `${ContextSingleton.getHost()}/api/loki/export?${exportQuery}`; }; -export const getClusters = (): Promise => { - return axios.get(ContextSingleton.getHost() + '/api/resources/clusters').then(r => { +export const getStatus = (forcedNamespace?: string): Promise => { + const params = { namespace: forcedNamespace }; + return axios.get(ContextSingleton.getHost() + '/api/status', { params }).then(r => { if (r.status >= 400) { throw new Error(`${r.statusText} [code=${r.status}]`); } @@ -63,8 +65,9 @@ export const getClusters = (): Promise => { }); }; -export const getZones = (): Promise => { - return axios.get(ContextSingleton.getHost() + '/api/resources/zones').then(r => { +export const getClusters = (forcedNamespace?: string): Promise => { + const params = { namespace: forcedNamespace }; + return axios.get(ContextSingleton.getHost() + '/api/resources/clusters', { params }).then(r => { if (r.status >= 400) { throw new Error(`${r.statusText} [code=${r.status}]`); } @@ -72,8 +75,9 @@ export const getZones = (): Promise => { }); }; -export const getNamespaces = (): Promise => { - return axios.get(ContextSingleton.getHost() + '/api/resources/namespaces').then(r => { +export const getZones = (forcedNamespace?: string): Promise => { + const params = { namespace: forcedNamespace }; + return axios.get(ContextSingleton.getHost() + '/api/resources/zones', { params }).then(r => { if (r.status >= 400) { throw new Error(`${r.statusText} [code=${r.status}]`); } @@ -81,11 +85,22 @@ export const getNamespaces = (): Promise => { }); }; -export const getResources = (namespace: string, kind: string): Promise => { +export const getNamespaces = (forcedNamespace?: string): Promise => { + const params = { namespace: forcedNamespace }; + return axios.get(ContextSingleton.getHost() + '/api/resources/namespaces', { params }).then(r => { + if (r.status >= 400) { + throw new Error(`${r.statusText} [code=${r.status}]`); + } + return r.data; + }); +}; + +export const getResources = (namespace: string, kind: string, forcedNamespace?: string): Promise => { + const params = { namespace: forcedNamespace }; const url = namespace ? `${ContextSingleton.getHost()}/api/resources/namespace/${namespace}/kind/${kind}/names` : `${ContextSingleton.getHost()}/api/resources/kind/${kind}/names`; - return axios.get(url).then(r => { + return axios.get(url, { params }).then(r => { if (r.status >= 400) { throw new Error(`${r.statusText} [code=${r.status}]`); } @@ -123,7 +138,7 @@ const getFlowMetricsGeneric = ( params: FlowQuery, mapper: (raw: AggregatedQueryResponse) => T ): Promise<{ metrics: T; stats: Stats }> => { - return axios.get(ContextSingleton.getHost() + '/api/loki/flow/metrics', { params }).then(r => { + return axios.get(ContextSingleton.getHost() + '/api/flow/metrics', { params }).then(r => { if (r.status >= 400) { throw new Error(`${r.statusText} [code=${r.status}]`); } diff --git a/web/src/components/drawer/netflow-traffic-drawer.tsx b/web/src/components/drawer/netflow-traffic-drawer.tsx index 49601f1b4..9098a4b22 100644 --- a/web/src/components/drawer/netflow-traffic-drawer.tsx +++ b/web/src/components/drawer/netflow-traffic-drawer.tsx @@ -31,7 +31,6 @@ import { SearchEvent, SearchHandle } from '../search/search'; import { NetflowOverview, NetflowOverviewHandle } from '../tabs/netflow-overview/netflow-overview'; import { NetflowTable, NetflowTableHandle } from '../tabs/netflow-table/netflow-table'; import { NetflowTopology, NetflowTopologyHandle } from '../tabs/netflow-topology/netflow-topology'; -import { LinksOverflow } from '../toolbar/links-overflow'; import ElementPanel from './element/element-panel'; import RecordPanel from './record/record-panel'; @@ -148,28 +147,21 @@ export const NetflowTrafficDrawer: React.FC = React.f [props.setFilters] ); - const filterLinks = React.useCallback(() => { - const defFilters = props.defaultFilters; - return ( - 0 && !filtersEqual(props.filters.list, defFilters) - }, - { - id: 'clear-all-filters', - label: t('Clear all'), - onClick: props.clearFilters, - enabled: props.filters.list.length > 0 - } - ]} - /> - ); - }, [props.defaultFilters, props.resetDefaultFilters, props.filters.list, props.clearFilters]); + const getResetDefaultFiltersProp = React.useCallback(() => { + if (props.defaultFilters.length > 0 && !filtersEqual(props.filters.list, props.defaultFilters)) { + return props.resetDefaultFilters; + } + return undefined; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.defaultFilters, props.resetDefaultFilters, props.filters.list]); + + const getClearFiltersProp = React.useCallback(() => { + if (props.filters.list.length > 0) { + return props.clearFilters; + } + return undefined; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.filters.list, props.clearFilters]); const getTopologyMetrics = React.useCallback(() => { switch (props.topologyMetricType) { @@ -252,7 +244,8 @@ export const NetflowTrafficDrawer: React.FC = React.f metrics={props.metrics} loading={props.loading} isDark={props.isDarkTheme} - filterActionLinks={filterLinks()} + resetDefaultFilters={getResetDefaultFiltersProp()} + clearFilters={getClearFiltersProp()} truncateLength={props.overviewTruncateLength} focus={props.overviewFocus} setFocus={props.setOverviewFocus} @@ -275,7 +268,8 @@ export const NetflowTrafficDrawer: React.FC = React.f } columnSizes={props.columnSizes} setColumnSizes={props.setColumnSizes} - filterActionLinks={filterLinks()} + resetDefaultFilters={getResetDefaultFiltersProp()} + clearFilters={getClearFiltersProp()} isDark={props.isDarkTheme} /> ); @@ -303,6 +297,8 @@ export const NetflowTrafficDrawer: React.FC = React.f searchEvent={props.searchEvent} isDark={props.isDarkTheme} allowedScopes={props.allowedScopes} + resetDefaultFilters={getResetDefaultFiltersProp()} + clearFilters={getClearFiltersProp()} /> ); break; diff --git a/web/src/components/messages/empty.css b/web/src/components/messages/empty.css new file mode 100644 index 000000000..94c14d2e8 --- /dev/null +++ b/web/src/components/messages/empty.css @@ -0,0 +1,24 @@ +.empty-text-content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.netobserv-empty-icon, +.netobserv-empty-message { + margin-bottom: 1em; +} + +.empty-text-content>p { + margin-top: 1em !important; +} + +.empty-text-content>blockquote { + width: 80%; +} + +.empty-text-content ul { + margin-top: 0.5em; + text-align: initial; +} diff --git a/web/src/components/messages/empty.tsx b/web/src/components/messages/empty.tsx new file mode 100644 index 000000000..3b30398b7 --- /dev/null +++ b/web/src/components/messages/empty.tsx @@ -0,0 +1,95 @@ +import { + Bullseye, + EmptyState, + EmptyStateBody, + EmptyStateIcon, + EmptyStateVariant, + Spinner, + Text, + TextContent, + TextVariants, + Title +} from '@patternfly/react-core'; +import { SearchIcon } from '@patternfly/react-icons'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Status } from '../../api/loki'; +import { getStatus } from '../../api/routes'; +import { Config } from '../../model/config'; +import { ContextSingleton } from '../../utils/context'; +import { getHTTPErrorDetails } from '../../utils/errors'; +import './empty.css'; +import { SecondaryAction } from './secondary-action'; +import { StatusTexts } from './status-texts'; + +export interface EmptyProps { + showDetails: boolean; + resetDefaultFilters?: (c?: Config) => void; + clearFilters?: () => void; +} + +export const Empty: React.FC = ({ showDetails, resetDefaultFilters, clearFilters }) => { + const { t } = useTranslation('plugin__netobserv-plugin'); + const [namespacesLoading, setNamespacesLoading] = React.useState(showDetails); + const [status, setStatus] = React.useState(); + const [statusError, setStatusError] = React.useState(); + + React.useEffect(() => { + //jest crashing on getNamespaces not defined so we need to ensure the function is defined here + if (!getStatus || !showDetails) { + return; + } + + getStatus(ContextSingleton.getForcedNamespace()) + .then(status => { + console.info('status result', status); + setStatus(status); + setStatusError(undefined); + }) + .catch(err => { + const errorMessage = getHTTPErrorDetails(err); + console.error(errorMessage); + setStatusError(errorMessage); + setStatus(undefined); + }) + .finally(() => { + setNamespacesLoading(false); + }); + }, [showDetails]); + + return ( + + + + {t('No results found')} + + {showDetails && ( + + {statusError === undefined && ( + + {t('Clear or reset filters and try again.')} + + )} + {statusError !== undefined && ( + + {t('Check for errors in health dashboard. Status endpoint is returning: {{statusError}}', { + statusError + })} + + )} + + {namespacesLoading && ( + + + + )} + {status && } + + + )} + {showDetails && } + + ); +}; + +export default Empty; diff --git a/web/src/components/messages/error.tsx b/web/src/components/messages/error.tsx index 6bf704538..add27904e 100644 --- a/web/src/components/messages/error.tsx +++ b/web/src/components/messages/error.tsx @@ -6,7 +6,6 @@ import { EmptyState, EmptyStateBody, EmptyStateIcon, - EmptyStateSecondaryActions, Spinner, Text, TextContent, @@ -19,9 +18,13 @@ import { ExclamationCircleIcon, ExternalLinkSquareAltIcon } from '@patternfly/re 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 { Status } from '../../api/loki'; +import { getBuildInfo, getLimits, getLokiMetrics, getStatus } from '../../api/routes'; +import { ContextSingleton } from '../../utils/context'; import { getHTTPErrorDetails, getPromUnsupportedError, isPromUnsupportedError } from '../../utils/errors'; import './error.css'; +import { SecondaryAction } from './secondary-action'; +import { StatusTexts } from './status-texts'; export type Size = 's' | 'm' | 'l'; @@ -40,14 +43,16 @@ export interface ErrorProps { export const Error: React.FC = ({ title, error, isLokiRelated }) => { const { t } = useTranslation('plugin__netobserv-plugin'); - const [loading, setLoading] = React.useState(isLokiRelated); - const [ready, setReady] = React.useState(); + const [lokiLoading, setLokiLoading] = React.useState(isLokiRelated); + const [statusLoading, setStatusLoading] = React.useState(true); + const [status, setStatus] = React.useState(); + const [statusError, setStatusError] = React.useState(); const [infoName, setInfoName] = React.useState(); const [info, setInfo] = React.useState(); const updateInfo = React.useCallback( (type: LokiInfo) => { - setLoading(true); + setLokiLoading(true); switch (type) { case LokiInfo.Build: @@ -58,7 +63,7 @@ export const Error: React.FC = ({ title, error, isLokiRelated }) => setInfo(getHTTPErrorDetails(err)); }) .finally(() => { - setLoading(false); + setLokiLoading(false); }); break; case LokiInfo.Limits: @@ -69,7 +74,7 @@ export const Error: React.FC = ({ title, error, isLokiRelated }) => setInfo(getHTTPErrorDetails(err)); }) .finally(() => { - setLoading(false); + setLokiLoading(false); }); break; case LokiInfo.Metrics: @@ -80,14 +85,14 @@ export const Error: React.FC = ({ title, error, isLokiRelated }) => setInfo(getHTTPErrorDetails(err)); }) .finally(() => { - setLoading(false); + setLokiLoading(false); }); break; case LokiInfo.Hidden: default: setInfoName(undefined); setInfo(undefined); - setLoading(false); + setLokiLoading(false); break; } }, @@ -99,21 +104,27 @@ export const Error: React.FC = ({ title, error, isLokiRelated }) => }, [error]); React.useEffect(() => { - //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); - }); + //jest crashing on getStatus not defined so we need to ensure the function is defined here + if (!getStatus) { + return; } - }, [isLokiRelated]); + + getStatus(ContextSingleton.getForcedNamespace()) + .then(status => { + console.info('status result', status); + setStatus(status); + setStatusError(undefined); + }) + .catch(err => { + const errorMessage = getHTTPErrorDetails(err); + console.error(errorMessage); + setStatusError(errorMessage); + setStatus(undefined); + }) + .finally(() => { + setStatusLoading(false); + }); + }, []); return (
@@ -200,8 +211,8 @@ export const Error: React.FC = ({ title, error, isLokiRelated }) => {...props} target="_blank" to={{ - pathname: - 'https://github.com/netobserv/documents/blob/main/loki_operator.md#loki-input-size-too-long-error' + pathname: 'https://github.com/netobserv/documents/blob/main/loki_operator.md', + hash: 'loki-input-size-too-long-error' }} /> )} @@ -210,13 +221,6 @@ export const Error: React.FC = ({ title, error, isLokiRelated }) => )} - {ready === false && ( - <> - - {t(`Check if Loki is running correctly. '/ready' endpoint should respond "ready"`)} - - - )} {error.includes('Network Error') && ( {t(`Check your connectivity with cluster / console plugin pod`)} @@ -225,27 +229,39 @@ export const Error: React.FC = ({ title, error, isLokiRelated }) => {(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 && ( + <> + + {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`)} + + + )} )} + {status && } + {statusError && ( + + {t('Check for errors in health dashboard. Status endpoint is returning: {{statusError}}', { + statusError + })} + + )} } @@ -262,7 +278,8 @@ export const Error: React.FC = ({ title, error, isLokiRelated }) => to={{ pathname: // eslint-disable-next-line max-len - 'https://docs.openshift.com/container-platform/latest/observability/network_observability/installing-operators.html#network-observability-loki-installation_network_observability' + 'https://docs.openshift.com/container-platform/latest/observability/network_observability/installing-operators.html', + hash: 'network-observability-loki-installation_network_observability' }} /> )} @@ -285,33 +302,13 @@ export const Error: React.FC = ({ title, error, isLokiRelated }) => )} - {isLokiRelated && ( - - - - - - - )} + updateInfo(LokiInfo.Metrics)} + showBuildInfo={() => updateInfo(LokiInfo.Build)} + showConfigLimits={() => updateInfo(LokiInfo.Limits)} + /> - {loading ? ( + {lokiLoading || statusLoading ? ( diff --git a/web/src/components/messages/secondary-action.tsx b/web/src/components/messages/secondary-action.tsx new file mode 100644 index 000000000..7408f54a1 --- /dev/null +++ b/web/src/components/messages/secondary-action.tsx @@ -0,0 +1,87 @@ +import { Button, EmptyStateSecondaryActions } from '@patternfly/react-core'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { Config } from '../../model/config'; +import { ContextSingleton } from '../../utils/context'; + +export interface EmptyProps { + resetDefaultFilters?: (c?: Config) => void; + clearFilters?: () => void; + showMetrics?: () => void; + showBuildInfo?: () => void; + showConfigLimits?: () => void; +} + +export const SecondaryAction: React.FC = ({ + resetDefaultFilters, + clearFilters, + showMetrics, + showBuildInfo, + showConfigLimits +}) => { + const { t } = useTranslation('plugin__netobserv-plugin'); + const flowCollectorK8SModel = ContextSingleton.getFlowCollectorK8SModel(); + + return ( + <> + + {flowCollectorK8SModel && ( + + )} + + {clearFilters && ( + + )} + {resetDefaultFilters && ( + + )} + {showMetrics && ( + + )} + {showBuildInfo && ( + + )} + {showConfigLimits && ( + + )} + + + ); +}; + +export default SecondaryAction; diff --git a/web/src/components/messages/status-texts.tsx b/web/src/components/messages/status-texts.tsx new file mode 100644 index 000000000..7671edb47 --- /dev/null +++ b/web/src/components/messages/status-texts.tsx @@ -0,0 +1,51 @@ +import { Text, TextVariants } from '@patternfly/react-core'; +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Status } from '../../api/loki'; + +export interface StatusProps { + status: Status; +} + +export const StatusTexts: React.FC = ({ status }) => { + const { t } = useTranslation('plugin__netobserv-plugin'); + + return ( + <> + {status && status.isLokiReady === false && ( + + {t(`Check if Loki is running correctly. '/ready' endpoint should respond "ready"`)} + + )} + {status && status.isAllowLoki && status.lokiNamespacesCount === 0 && ( + {t(`Can't find any namespace label in your Loki storage.`)} + )} + {status && status.isAllowProm && status.promNamespacesCount === 0 && ( + + {t(`Can't find any namespace label in your Prometheus storage.`)} + + )} + {status && + ((status.isAllowLoki && status.lokiNamespacesCount === 0) || + (status.isAllowProm && status.promNamespacesCount === 0)) && ( + + {t( + // eslint-disable-next-line max-len + `If this is the first time you run the operator, check FlowCollector status and health dashboard to ensure there is no error and flows are ingested. This can take some time.` + )} + + )} + {status && + status.isAllowLoki && + status.isLokiReady && + status.isAllowProm && + status.lokiNamespacesCount !== status.promNamespacesCount && ( + + {t(`Loki and Prom storages are not consistent. Check health dashboard for errors.`)} + + )} + + ); +}; + +export default StatusTexts; diff --git a/web/src/components/netflow-traffic-tab.tsx b/web/src/components/netflow-traffic-tab.tsx index 542ac1791..54e687b18 100644 --- a/web/src/components/netflow-traffic-tab.tsx +++ b/web/src/components/netflow-traffic-tab.tsx @@ -1,4 +1,4 @@ -import { K8sResourceCommon, PageComponentProps } from '@openshift-console/dynamic-plugin-sdk'; +import { K8sResourceCommon } from '@openshift-console/dynamic-plugin-sdk'; import { Bullseye, EmptyState, @@ -19,6 +19,18 @@ import { usePrevious } from '../utils/previous-hook'; import Error from './messages/error'; import NetflowTrafficParent from './netflow-traffic-parent'; +type NetflowTrafficTabProps = { + match?: { + params?: { + ns?: string; + }; + }; + obj?: R; + params?: { + ns?: string; + }; +}; + type RouteProps = K8sResourceCommon & { spec: { to: { @@ -37,7 +49,7 @@ type HPAProps = K8sResourceCommon & { }; }; -export const NetflowTrafficTab: React.FC = ({ obj }) => { +export const NetflowTrafficTab: React.FC = ({ match, obj, params }) => { const { t } = useTranslation('plugin__netobserv-plugin'); //default to 800 to allow content to be rendered in tests @@ -195,7 +207,12 @@ export const NetflowTrafficTab: React.FC = ({ obj }) => { } else if (forcedFilters) { return (
- +
); } else { diff --git a/web/src/components/netflow-traffic.css b/web/src/components/netflow-traffic.css index d7761f1bc..ad1062506 100644 --- a/web/src/components/netflow-traffic.css +++ b/web/src/components/netflow-traffic.css @@ -232,11 +232,11 @@ span.pf-c-button__icon.pf-m-start { } .query-option-tooltip-text { - color: #fff; + color: #fff !important; } .netobserv-tooltip-text { - color: #fff; + color: #fff !important; } .netobserv-no-child-margin>* { diff --git a/web/src/components/netflow-traffic.tsx b/web/src/components/netflow-traffic.tsx index bc4438214..de94d5eb0 100644 --- a/web/src/components/netflow-traffic.tsx +++ b/web/src/components/netflow-traffic.tsx @@ -76,7 +76,7 @@ export const NetflowTraffic: React.FC = ({ const { t } = useTranslation('plugin__netobserv-plugin'); const isDarkTheme = useTheme(); const [extensions] = useResolvedExtensions(isModelFeatureFlag); - ContextSingleton.setContext(extensions); + ContextSingleton.setContext(extensions, forcedNamespace); const model = netflowTrafficModel(); diff --git a/web/src/components/slider/scope-slider.tsx b/web/src/components/slider/scope-slider.tsx index 878218d37..96f0e560b 100644 --- a/web/src/components/slider/scope-slider.tsx +++ b/web/src/components/slider/scope-slider.tsx @@ -31,7 +31,7 @@ export const ScopeSlider: React.FC = ({ scope, setScope, allow * Non supported dimensions simply hide the slider from the view * since we can manage scopes from advanced view */ - const canDisplay = sizePx > 350 && sizePx < 2000; + const canDisplay = sizePx > 450 && sizePx < 2000; const isBig = sizePx > 700; return (
', () => { customMetrics: new Map(), totalCustomMetrics: new Map() }, - filterActionLinks: <>, truncateLength: TruncateLength.M, forcedSize: { width: 800, height: 800 } as DOMRect }; diff --git a/web/src/components/tabs/netflow-overview/netflow-overview.tsx b/web/src/components/tabs/netflow-overview/netflow-overview.tsx index 3db0cf3df..d70e42509 100644 --- a/web/src/components/tabs/netflow-overview/netflow-overview.tsx +++ b/web/src/components/tabs/netflow-overview/netflow-overview.tsx @@ -1,14 +1,4 @@ -import { - Bullseye, - EmptyState, - EmptyStateBody, - EmptyStateIcon, - EmptyStateVariant, - Flex, - Spinner, - Title -} from '@patternfly/react-core'; -import { SearchIcon } from '@patternfly/react-icons'; +import { Bullseye, Flex, Spinner } from '@patternfly/react-core'; import _ from 'lodash'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; @@ -29,7 +19,7 @@ import { TotalRateMetrics } from '../../../api/loki'; import { getFlowGenericMetrics } from '../../../api/routes'; -import { Feature } from '../../../model/config'; +import { Config, Feature } from '../../../model/config'; import { FlowQuery, FlowScope, RecordType } from '../../../model/flow-query'; import { getStat } from '../../../model/metrics'; import { TimeRange } from '../../../utils/datetime'; @@ -55,6 +45,7 @@ import { formatPort } from '../../../utils/port'; import { usePrevious } from '../../../utils/previous-hook'; import { formatProtocol } from '../../../utils/protocol'; import { TruncateLength } from '../../dropdowns/truncate-dropdown'; +import { Empty } from '../../messages/empty'; import { MetricsDonut } from '../../metrics/metrics-donut'; import { MetricsGraph } from '../../metrics/metrics-graph'; import { MetricsGraphWithTotal } from '../../metrics/metrics-graph-total'; @@ -91,7 +82,8 @@ export interface NetflowOverviewProps { metrics: NetflowMetrics; loading?: boolean; isDark?: boolean; - filterActionLinks: JSX.Element; + resetDefaultFilters?: (c?: Config) => void; + clearFilters?: () => void; truncateLength: TruncateLength; focus?: boolean; setFocus?: (v: boolean) => void; @@ -430,28 +422,28 @@ export const NetflowOverview: React.FC = React.forwardRef( [kebabMap] ); - const emptyGraph = React.useCallback(() => { - return ( -
- {props.loading ? ( - - - - ) : ( - - - - - {t('No results found')} - - {t('Clear or reset filters and try again.')} - {props.filterActionLinks} - - - )} -
- ); - }, [props.filterActionLinks, props.loading, t]); + const emptyGraph = React.useCallback( + (showDetails: boolean) => { + return ( +
+ {props.loading ? ( + + + + ) : ( + + + + )} +
+ ); + }, + [props.resetDefaultFilters, props.clearFilters, props.loading] + ); React.useEffect(() => { observeDOMRect(containerRef, containerSize, setContainerSize); @@ -691,7 +683,7 @@ export const NetflowOverview: React.FC = React.forwardRef( isDark={props.isDark} /> ) : ( - emptyGraph() + emptyGraph(!isFocus) ), kebab: ( = React.forwardRef( isDark={props.isDark} /> ) : ( - emptyGraph() + emptyGraph(!isFocus) ), kebab: setKebabOptions(id, opts)} />, bodyClassSmall: false, @@ -841,7 +833,7 @@ export const NetflowOverview: React.FC = React.forwardRef( isDark={props.isDark} /> ) : ( - emptyGraph() + emptyGraph(!isFocus) ), kebab: setKebabOptions(id, opts)} />, bodyClassSmall: options.graph!.type === 'donut', @@ -920,7 +912,7 @@ export const NetflowOverview: React.FC = React.forwardRef( /> ) ) : ( - emptyGraph() + emptyGraph(!isFocus) ), kebab: setKebabOptions(id, opts)} />, bodyClassSmall: options.graph!.type === 'donut', @@ -981,7 +973,7 @@ export const NetflowOverview: React.FC = React.forwardRef( /> ) ) : ( - emptyGraph() + emptyGraph(!isFocus) ), kebab: setKebabOptions(id, opts)} />, bodyClassSmall: options.graph!.type === 'donut', @@ -1042,7 +1034,7 @@ export const NetflowOverview: React.FC = React.forwardRef( /> ) ) : ( - emptyGraph() + emptyGraph(!isFocus) ), kebab: setKebabOptions(id, opts)} />, bodyClassSmall: options.graph!.type === 'donut', diff --git a/web/src/components/tabs/netflow-table/__tests__/netflow-table.spec.tsx b/web/src/components/tabs/netflow-table/__tests__/netflow-table.spec.tsx index 7a5d0e376..74187ce5e 100644 --- a/web/src/components/tabs/netflow-table/__tests__/netflow-table.spec.tsx +++ b/web/src/components/tabs/netflow-table/__tests__/netflow-table.spec.tsx @@ -21,7 +21,6 @@ describe('', () => { allowPktDrops: true, size: 'm' as Size, onSelect: jest.fn(), - filterActionLinks: <>, setColumns: jest.fn(), columnSizes: {}, setColumnSizes: jest.fn() diff --git a/web/src/components/tabs/netflow-table/netflow-table.tsx b/web/src/components/tabs/netflow-table/netflow-table.tsx index a5207f060..f2eb294ed 100644 --- a/web/src/components/tabs/netflow-table/netflow-table.tsx +++ b/web/src/components/tabs/netflow-table/netflow-table.tsx @@ -1,20 +1,11 @@ -import { - Bullseye, - EmptyState, - EmptyStateBody, - EmptyStateIcon, - EmptyStateVariant, - Spinner, - Title -} from '@patternfly/react-core'; -import { SearchIcon } from '@patternfly/react-icons'; +import { Bullseye, Spinner } from '@patternfly/react-core'; import { SortByDirection, TableComposable, Tbody } from '@patternfly/react-table'; import * as _ from 'lodash'; import * as React from 'react'; -import { useTranslation } from 'react-i18next'; import { Record } from '../../../api/ipfix'; import { FlowMetricsResult, NetflowMetrics, RecordsResult, Stats } from '../../../api/loki'; +import { Config } from '../../../model/config'; import { FlowQuery } from '../../../model/flow-query'; import { Column, ColumnsId, ColumnSizeMap } from '../../../utils/columns'; import { TimeRange } from '../../../utils/datetime'; @@ -27,6 +18,7 @@ import { import { convertRemToPixels } from '../../../utils/panel'; import { usePrevious } from '../../../utils/previous-hook'; import { Size } from '../../dropdowns/table-display-dropdown'; +import { Empty } from '../../messages/empty'; import { NetflowTableHeader } from './netflow-table-header'; import NetflowTableRow from './netflow-table-row'; import './netflow-table.css'; @@ -59,15 +51,14 @@ export interface NetflowTableProps { size: Size; onSelect: (record?: Record) => void; loading?: boolean; - filterActionLinks: JSX.Element; + resetDefaultFilters?: (c?: Config) => void; + clearFilters?: () => void; isDark?: boolean; } // eslint-disable-next-line react/display-name export const NetflowTable: React.FC = React.forwardRef( (props, ref: React.Ref) => { - const { t } = useTranslation('plugin__netobserv-plugin'); - //default to 300 to allow content to be rendered in tests const [containerHeight, setContainerHeight] = React.useState(300); const previousContainerHeight = usePrevious(containerHeight); @@ -306,14 +297,11 @@ export const NetflowTable: React.FC = React.forwardRef( } else { return ( - - - - {t('No results found')} - - {t('Clear or reset filters and try again.')} - {props.filterActionLinks} - + ); } 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 3b1faf41f..f37b8680e 100644 --- a/web/src/components/tabs/netflow-topology/2d/topology-content.tsx +++ b/web/src/components/tabs/netflow-topology/2d/topology-content.tsx @@ -1,5 +1,5 @@ import { K8sModel } from '@openshift-console/dynamic-plugin-sdk'; -import { ValidatedOptions } from '@patternfly/react-core'; +import { Bullseye, ValidatedOptions } from '@patternfly/react-core'; import { createTopologyControlButtons, defaultControlButtonsOptions, @@ -19,6 +19,7 @@ import _ from 'lodash'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { TopologyMetrics } from '../../../../api/loki'; +import { Config } from '../../../../model/config'; import { Filter, FilterDefinition, Filters } from '../../../../model/filters'; import { FlowScope, MetricType, StatFunction } from '../../../../model/flow-query'; import { getStat, MetricScopeOptions } from '../../../../model/metrics'; @@ -37,6 +38,7 @@ import { TopologyOptions } from '../../../../model/topology'; import { usePrevious } from '../../../../utils/previous-hook'; +import { Empty } from '../../../messages/empty'; import { SearchEvent, SearchHandle } from '../../../search/search'; import { filterEvent, stepIntoEvent } from './styles/styleDecorators'; import './topology-content.css'; @@ -70,6 +72,8 @@ export interface TopologyContentProps { searchHandle: SearchHandle | null; searchEvent?: SearchEvent; isDark?: boolean; + resetDefaultFilters?: (c?: Config) => void; + clearFilters?: () => void; } export const TopologyContent: React.FC = ({ @@ -90,7 +94,9 @@ export const TopologyContent: React.FC = ({ onSelect, searchHandle, searchEvent, - isDark + isDark, + resetDefaultFilters, + clearFilters }) => { const { t } = useTranslation('plugin__netobserv-plugin'); const controller = useVisualizationController(); @@ -484,6 +490,14 @@ export const TopologyContent: React.FC = ({ useEventListener(graphLayoutEndEvent, onLayoutEnd); useEventListener(graphPositionChangeEvent, onLayoutPositionChange); + if (_.isEmpty(metrics) && _.isEmpty(droppedMetrics)) { + return ( + + + + ); + } + return ( void; + clearFilters?: () => void; } // eslint-disable-next-line react/display-name @@ -238,6 +240,8 @@ export const NetflowTopology: React.FC = React.forwardRef( searchHandle={props.searchHandle} searchEvent={props.searchEvent} isDark={props.isDark} + resetDefaultFilters={props.resetDefaultFilters} + clearFilters={props.clearFilters} /> ); diff --git a/web/src/model/netflow-traffic.ts b/web/src/model/netflow-traffic.ts index c7f45dbc9..9a6197538 100644 --- a/web/src/model/netflow-traffic.ts +++ b/web/src/model/netflow-traffic.ts @@ -9,6 +9,7 @@ import { ViewId } from '../components/netflow-traffic'; import { SearchEvent } from '../components/search/search'; import { Warning } from '../model/warnings'; import { Column, ColumnSizeMap } from '../utils/columns'; +import { ContextSingleton } from '../utils/context'; import { TimeRange } from '../utils/datetime'; import { useK8sModelsWithColors } from '../utils/k8s-models-hook'; import { @@ -56,6 +57,12 @@ export function netflowTrafficModel() { const [config, setConfig] = React.useState(defaultConfig); const k8sModels = useK8sModelsWithColors(); + // Find FlowCollector in supported kinds to be able to refer it + const flowCollectorModelKey = Object.keys(k8sModels).find(k => k.includes('FlowCollector')); + if (flowCollectorModelKey) { + ContextSingleton.setFlowCollectorK8SModel(k8sModels[flowCollectorModelKey]); + } + // Local storage const [queryParams, setQueryParams] = useLocalStorage(localStorageQueryParamsKey); const [disabledFilters, setDisabledFilters] = useLocalStorage(localStorageDisabledFiltersKey, {}); diff --git a/web/src/utils/context.ts b/web/src/utils/context.ts index 1cbeb1931..9ae2995cf 100644 --- a/web/src/utils/context.ts +++ b/web/src/utils/context.ts @@ -1,4 +1,9 @@ -import { ExtensionK8sModel, ModelFeatureFlag, ResolvedExtension } from '@openshift-console/dynamic-plugin-sdk'; +import { + ExtensionK8sModel, + K8sModel, + ModelFeatureFlag, + ResolvedExtension +} from '@openshift-console/dynamic-plugin-sdk'; import _ from 'lodash'; export const DEFAULT_HOST = '/api/proxy/plugin/netobserv-plugin/backend'; @@ -6,6 +11,8 @@ export class ContextSingleton { private static instance: ContextSingleton; private isStandalone: boolean; private host: string; + private forcedNamespace?: string; + private flowCollectorK8SModel?: K8sModel; private constructor() { this.host = DEFAULT_HOST; @@ -26,9 +33,10 @@ export class ContextSingleton { flag: string; model: ExtensionK8sModel; } - >[] + >[], + forcedNamespace?: string ) { - const isStandalone = !_.isEmpty(extensions) && extensions[0]?.flags === 'dummy'; + const isStandalone = (!_.isEmpty(extensions) && extensions[0]?.flags?.required?.includes('dummy')) || false; const instance = ContextSingleton.getInstance(); instance.isStandalone = isStandalone; if (isStandalone) { @@ -36,6 +44,12 @@ export class ContextSingleton { } else { instance.host = DEFAULT_HOST; } + instance.forcedNamespace = forcedNamespace; + } + + public static setFlowCollectorK8SModel(model?: K8sModel) { + const instance = ContextSingleton.getInstance(); + instance.flowCollectorK8SModel = model; } public static isStandalone() { @@ -45,4 +59,12 @@ export class ContextSingleton { public static getHost() { return ContextSingleton.getInstance().host; } + + public static getForcedNamespace() { + return ContextSingleton.getInstance().forcedNamespace; + } + + public static getFlowCollectorK8SModel() { + return ContextSingleton.getInstance().flowCollectorK8SModel; + } } diff --git a/web/src/utils/filter-options.ts b/web/src/utils/filter-options.ts index 0baf28efc..3e45fc021 100644 --- a/web/src/utils/filter-options.ts +++ b/web/src/utils/filter-options.ts @@ -6,6 +6,7 @@ import { getClusters, getNamespaces, getResources, getZones } from '../api/route import { FilterOption } from '../model/filters'; import { splitResource, SplitStage } from '../model/resource'; import { autoCompleteCache } from './autocomplete-cache'; +import { ContextSingleton } from './context'; import { dnsErrors, dnsRCodes } from './dns'; import { DSCP_VALUES } from './dscp'; import { dropCauses, dropStates } from './pkt-drop'; @@ -57,23 +58,12 @@ const matchOptions = (opts: FilterOption[], match: string): FilterOption[] => { } }; -export const getNamespaceOptions = (value: string): Promise => { - const namespaces = autoCompleteCache.getNamespaces(); - if (namespaces) { - return Promise.resolve(matchOptions(namespaces.map(toFilterOption), value)); - } - return getNamespaces().then(ns => { - autoCompleteCache.setNamespaces(ns); - return matchOptions(ns.map(toFilterOption), value); - }); -}; - export const getClusterOptions = (value: string): Promise => { const clusters = autoCompleteCache.getClusters(); if (clusters) { return Promise.resolve(matchOptions(clusters.map(toFilterOption), value)); } - return getClusters().then(cs => { + return getClusters(ContextSingleton.getForcedNamespace()).then(cs => { autoCompleteCache.setClusters(cs); return matchOptions(cs.map(toFilterOption), value); }); @@ -84,18 +74,29 @@ export const getZoneOptions = (value: string): Promise => { if (zones) { return Promise.resolve(matchOptions(zones.map(toFilterOption), value)); } - return getZones().then(zs => { + return getZones(ContextSingleton.getForcedNamespace()).then(zs => { autoCompleteCache.setZones(zs); return matchOptions(zs.map(toFilterOption), value); }); }; +export const getNamespaceOptions = (value: string): Promise => { + const namespaces = autoCompleteCache.getNamespaces(); + if (namespaces) { + return Promise.resolve(matchOptions(namespaces.map(toFilterOption), value)); + } + return getNamespaces(ContextSingleton.getForcedNamespace()).then(ns => { + autoCompleteCache.setNamespaces(ns); + return matchOptions(ns.map(toFilterOption), value); + }); +}; + export const getNameOptions = (kind: string, namespace: string, name: string): Promise => { if (autoCompleteCache.hasNames(kind, namespace)) { const options = (autoCompleteCache.getNames(kind, namespace) || []).map(toFilterOption); return Promise.resolve(matchOptions(options, name)); } - return getResources(namespace, kind).then(values => { + return getResources(namespace, kind, ContextSingleton.getForcedNamespace()).then(values => { autoCompleteCache.setNames(kind, namespace, values); return matchOptions(values.map(toFilterOption), name); }); From 2878802bbf49ad60abfd80da0b3024e7ca6ac6d8 Mon Sep 17 00:00:00 2001 From: Julien Pinsonneau Date: Fri, 6 Sep 2024 14:01:39 +0200 Subject: [PATCH 2/9] improve labels needed error message --- pkg/handler/topology.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pkg/handler/topology.go b/pkg/handler/topology.go index fc66bcea9..9c6d326a6 100644 --- a/pkg/handler/topology.go +++ b/pkg/handler/topology.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/url" + "slices" "time" "github.com/netobserv/network-observability-console-plugin/pkg/config" @@ -338,7 +339,13 @@ func getEligiblePromMetric(promInventory *prometheus.Inventory, filters filters. } labelsNeeded = append(labelsNeeded, fromFilters...) if isDev { - labelsNeeded = append(labelsNeeded, fields.SrcNamespace) + if !slices.Contains(labelsNeeded, fields.SrcNamespace) { + labelsNeeded = append(labelsNeeded, fields.SrcNamespace) + } + + if !slices.Contains(labelsNeeded, fields.DstNamespace) { + labelsNeeded = append(labelsNeeded, fields.DstNamespace) + } } // Search for such metric From f71935699eb7b3ef58bdec9f141faea7b9d082de Mon Sep 17 00:00:00 2001 From: Julien Pinsonneau Date: Fri, 6 Sep 2024 16:32:09 +0200 Subject: [PATCH 3/9] NETOBSERV-1812 wait for config loaded for disabled filters --- web/src/components/netflow-traffic.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/src/components/netflow-traffic.tsx b/web/src/components/netflow-traffic.tsx index de94d5eb0..f6f747d30 100644 --- a/web/src/components/netflow-traffic.tsx +++ b/web/src/components/netflow-traffic.tsx @@ -691,7 +691,9 @@ export const NetflowTraffic: React.FC = ({ // update local storage enabled filters React.useEffect(() => { - model.setDisabledFilters(getDisabledFiltersRecord(model.filters.list)); + if (initState.current.includes('configLoaded')) { + model.setDisabledFilters(getDisabledFiltersRecord(model.filters.list)); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [model.filters]); From d3f6d53d46ec93ad04c37a4d96da8d7fa34d602a Mon Sep 17 00:00:00 2001 From: Julien Pinsonneau <91894519+jpinsonneau@users.noreply.github.com> Date: Mon, 16 Sep 2024 11:56:10 +0200 Subject: [PATCH 4/9] Update web/src/components/messages/status-texts.tsx Co-authored-by: Joel Takvorian --- web/src/components/messages/status-texts.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/messages/status-texts.tsx b/web/src/components/messages/status-texts.tsx index 7671edb47..5f5b94876 100644 --- a/web/src/components/messages/status-texts.tsx +++ b/web/src/components/messages/status-texts.tsx @@ -12,7 +12,7 @@ export const StatusTexts: React.FC = ({ status }) => { return ( <> - {status && status.isLokiReady === false && ( + {status && status.isAllowLoki && status.isLokiReady === false && ( {t(`Check if Loki is running correctly. '/ready' endpoint should respond "ready"`)} From 3f97ccda8204a6717eac68b19d8639b442eff20f Mon Sep 17 00:00:00 2001 From: Joel Takvorian Date: Tue, 13 Aug 2024 13:34:03 +0200 Subject: [PATCH 5/9] NETOBSERV-1798: fix auto-completion as non-admin - When there's permission issue with prom user, fallback on using loki (like we also do for regular metrics queries) - Do not try using prom for labels not in prom metrics - Fix misleading error messages - Do not disallow filter creation in case of autocompletion error --- pkg/handler/resources.go | 33 ++++++++++++++----- pkg/prometheus/client.go | 29 +++++++++------- .../toolbar/filters/autocomplete-filter.tsx | 4 +-- web/src/model/filters.ts | 14 +++++--- 4 files changed, 53 insertions(+), 27 deletions(-) diff --git a/pkg/handler/resources.go b/pkg/handler/resources.go index 2e70f2e73..0272d3ecf 100644 --- a/pkg/handler/resources.go +++ b/pkg/handler/resources.go @@ -34,7 +34,7 @@ func (h *Handlers) GetClusters(ctx context.Context) func(w http.ResponseWriter, // Fetch and merge values for K8S_ClusterName values, code, err := h.getLabelValues(ctx, clients, fields.Cluster) if err != nil { - writeError(w, code, "Error while fetching label cluster values from Loki: "+err.Error()) + writeError(w, code, err.Error()) return } @@ -65,14 +65,14 @@ func (h *Handlers) GetZones(ctx context.Context) func(w http.ResponseWriter, r * // Fetch and merge values for SrcK8S_Zone and DstK8S_Zone values1, code, err := h.getLabelValues(ctx, clients, fields.SrcZone) if err != nil { - writeError(w, code, "Error while fetching label source zone values from Loki: "+err.Error()) + writeError(w, code, err.Error()) return } values = append(values, values1...) values2, code, err := h.getLabelValues(ctx, clients, fields.DstZone) if err != nil { - writeError(w, code, "Error while fetching label destination zone values from Loki: "+err.Error()) + writeError(w, code, err.Error()) return } values = append(values, values2...) @@ -104,14 +104,14 @@ func (h *Handlers) GetNamespaces(ctx context.Context) func(w http.ResponseWriter // Fetch and merge values for SrcK8S_Namespace and DstK8S_Namespace values1, code, err := h.getLabelValues(ctx, clients, fields.SrcNamespace) if err != nil { - writeError(w, code, "Error while fetching label source namespace values from Loki: "+err.Error()) + writeError(w, code, err.Error()) return } values = append(values, values1...) values2, code, err := h.getLabelValues(ctx, clients, fields.DstNamespace) if err != nil { - writeError(w, code, "Error while fetching label destination namespace values from Loki: "+err.Error()) + writeError(w, code, err.Error()) return } values = append(values, values2...) @@ -123,10 +123,27 @@ func (h *Handlers) GetNamespaces(ctx context.Context) func(w http.ResponseWriter func (h *Handlers) getLabelValues(ctx context.Context, cl clients, label string) ([]string, int, error) { if h.PromInventory != nil && h.PromInventory.LabelExists(label) { - return prometheus.GetLabelValues(ctx, cl.promAdmin, label, nil) + resp, code, err := prometheus.GetLabelValues(ctx, cl.promAdmin, label, nil) + if err != nil { + if code == http.StatusUnauthorized || code == http.StatusForbidden { + // In case this was a prometheus 401 / 403 error, the query is repeated with Loki + // This is because multi-tenancy is currently not managed for prom datasource, hence such queries have to go with Loki + // Unfortunately we don't know a safe and generic way to pre-flight check if the user will be authorized + hlog.Info("Retrying with Loki...") + // continuing with loki below + } else { + return nil, code, fmt.Errorf("error while fetching label %s values from Prometheus: %w", label, err) + } + } else { + return resp, code, nil + } } if h.Cfg.IsLokiEnabled() { - return getLokiLabelValues(h.Cfg.Loki.URL, cl.loki, label) + resp, code, err := getLokiLabelValues(h.Cfg.Loki.URL, cl.loki, label) + if err != nil { + return nil, code, fmt.Errorf("error while fetching label %s values from Loki: %w", label, err) + } + return resp, code, nil } // Loki disabled AND label not managed in metrics => send an error return nil, http.StatusBadRequest, fmt.Errorf("label %s not found in Prometheus metrics", label) @@ -186,7 +203,7 @@ func (h *Handlers) getNamesForPrefix(ctx context.Context, cl clients, prefix, ki searchField = prefix + fields.Name } - if h.Cfg.IsPromEnabled() { + if h.Cfg.IsPromEnabled() && h.PromInventory.LabelExists(searchField) { // Label match query (any metric) q := prometheus.QueryFilters("", filts) return prometheus.GetLabelValues(ctx, cl.promAdmin, searchField, []string{q}) diff --git a/pkg/prometheus/client.go b/pkg/prometheus/client.go index 787430743..843590ad2 100644 --- a/pkg/prometheus/client.go +++ b/pkg/prometheus/client.go @@ -79,15 +79,7 @@ func executeQueryRange(ctx context.Context, cl api.Client, q *Query) (pmod.Value } if err != nil { log.Tracef("Error:\n%v", err) - code = http.StatusServiceUnavailable - var promError *v1.Error - if errors.As(err, &promError) { - if promError.Type == v1.ErrClient && strings.Contains(promError.Msg, "401") { - code = http.StatusUnauthorized - } else if promError.Type == v1.ErrClient && strings.Contains(promError.Msg, "403") { - code = http.StatusForbidden - } - } + code = translateErrorCode(err) return nil, code, fmt.Errorf("error from Prometheus query: %w", err) } @@ -125,12 +117,13 @@ func GetLabelValues(ctx context.Context, cl api.Client, label string, match []st log.Debugf("GetLabelValues: %s", label) v1api := v1.NewAPI(cl) result, warnings, err := v1api.LabelValues(ctx, label, match, time.Now().Add(-3*time.Hour), time.Now()) - if err != nil { - return nil, http.StatusServiceUnavailable, err - } if len(warnings) > 0 { log.Infof("GetLabelValues warnings: %v", warnings) } + if err != nil { + code := translateErrorCode(err) + return nil, code, fmt.Errorf("could not get label values: %w", err) + } log.Tracef("Result:\n%v", result) var asStrings []string for _, s := range result { @@ -138,3 +131,15 @@ func GetLabelValues(ctx context.Context, cl api.Client, label string, match []st } return asStrings, http.StatusOK, nil } + +func translateErrorCode(err error) int { + var promError *v1.Error + if errors.As(err, &promError) { + if promError.Type == v1.ErrClient && strings.Contains(promError.Msg, "401") { + return http.StatusUnauthorized + } else if promError.Type == v1.ErrClient && strings.Contains(promError.Msg, "403") { + return http.StatusForbidden + } + } + return http.StatusServiceUnavailable +} diff --git a/web/src/components/toolbar/filters/autocomplete-filter.tsx b/web/src/components/toolbar/filters/autocomplete-filter.tsx index 1d1b69c9e..0288790b8 100644 --- a/web/src/components/toolbar/filters/autocomplete-filter.tsx +++ b/web/src/components/toolbar/filters/autocomplete-filter.tsx @@ -87,9 +87,7 @@ export const AutocompleteFilter: React.FC = ({ setCurrentValue(newValue); filterDefinition .getOptions(newValue) - .then(opts => { - setOptions(opts); - }) + .then(setOptions) .catch(err => { const errorMessage = getHTTPErrorDetails(err); setMessageWithDelay(errorMessage); diff --git a/web/src/model/filters.ts b/web/src/model/filters.ts index 46872b3b2..6847c330e 100644 --- a/web/src/model/filters.ts +++ b/web/src/model/filters.ts @@ -93,12 +93,18 @@ export interface FilterOption { } export const createFilterValue = (def: FilterDefinition, value: string): Promise => { - return def.getOptions(value).then(opts => { - const option = opts.find(opt => opt.name === value || opt.value === value); - return option + return def + .getOptions(value) + .then(opts => { + const option = opts.find(opt => opt.name === value || opt.value === value); + return option ? { v: option.value, display: option.name } : { v: value, display: value === undefinedValue ? 'n/a' : undefined }; - }); + }) + .catch(_ => { + // In case of error, still create the minimal possible FilterValue + return { v: value }; + }); }; export const hasEnabledFilterValues = (filter: Filter) => { From 02f8a35dd5eabbf4d14273ec9aeb36f0624d4468 Mon Sep 17 00:00:00 2001 From: Joel Takvorian Date: Mon, 16 Sep 2024 12:36:21 +0200 Subject: [PATCH 6/9] Add prom permission info, move error into status model --- pkg/handler/loki.go | 2 +- pkg/handler/status.go | 79 ++++++++++---------- web/locales/en/plugin__netobserv-plugin.json | 8 +- web/src/api/loki.ts | 16 ++-- web/src/components/messages/error.tsx | 67 ++++++++++++----- web/src/components/messages/status-texts.tsx | 54 +++++++++---- web/src/model/filters.ts | 4 +- 7 files changed, 147 insertions(+), 83 deletions(-) diff --git a/pkg/handler/loki.go b/pkg/handler/loki.go index c99997b85..c5fe4ac8a 100644 --- a/pkg/handler/loki.go +++ b/pkg/handler/loki.go @@ -135,7 +135,7 @@ func executeLokiQuery(flowsURL string, lokiClient httpclient.Caller) ([]byte, in } if code != http.StatusOK { newCode, msg := getLokiError(resp, code) - return nil, newCode, fmt.Errorf("[%d] %s", code, msg) + return nil, newCode, fmt.Errorf("Error from Loki query: [%d] %s", code, msg) } return resp, http.StatusOK, nil } diff --git a/pkg/handler/status.go b/pkg/handler/status.go index 90db9fff2..cdcaa8a89 100644 --- a/pkg/handler/status.go +++ b/pkg/handler/status.go @@ -7,12 +7,16 @@ import ( ) type Status struct { - IsAllowProm bool `yaml:"isAllowProm" json:"isAllowProm"` - PromNamespacesCount int `yaml:"promNamespacesCount" json:"promNamespacesCount"` - IsAllowLoki bool `yaml:"isAllowLoki" json:"isAllowLoki"` - LokiNamespacesCount int `yaml:"lokiNamespacesCount" json:"lokiNamespacesCount"` - IsLokiReady bool `yaml:"isLokiReady" json:"isLokiReady"` - IsConsistent bool `yaml:"isConsistent" json:"isConsistent"` + Loki DatasourceStatus `yaml:"loki" json:"loki"` + Prometheus DatasourceStatus `yaml:"prometheus" json:"prometheus"` +} + +type DatasourceStatus struct { + IsEnabled bool `yaml:"isEnabled" json:"isEnabled"` + NamespacesCount int `yaml:"namespacesCount" json:"namespacesCount"` + IsReady bool `yaml:"isReady" json:"isReady"` + Error string `yaml:"error" json:"error"` + ErrorCode int `yaml:"errorCode" json:"errorCode"` } func (h *Handlers) Status(ctx context.Context) func(w http.ResponseWriter, r *http.Request) { @@ -22,52 +26,47 @@ func (h *Handlers) Status(ctx context.Context) func(w http.ResponseWriter, r *ht isDev := namespace != "" status := Status{ - IsAllowProm: h.Cfg.IsPromEnabled(), - IsAllowLoki: h.Cfg.IsLokiEnabled(), + Prometheus: DatasourceStatus{IsEnabled: h.Cfg.IsPromEnabled()}, + Loki: DatasourceStatus{IsEnabled: h.Cfg.IsLokiEnabled()}, } - if status.IsAllowProm { + if status.Prometheus.IsEnabled { promClients, err := newPromClients(h.Cfg, r.Header, namespace) if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - // get namespaces using Prom - promNamespaces, code, err := h.getNamespacesValues(ctx, promClients, isDev) - if err != nil { - writeError(w, code, "Error while fetching label namespace values from Prometheus: "+err.Error()) - return + status.Prometheus.Error = err.Error() + status.Prometheus.ErrorCode = http.StatusInternalServerError + } else { + // Get namespaces using Prom + promNamespaces, code, err := h.getNamespacesValues(ctx, promClients, isDev) + if err != nil { + status.Prometheus.Error = "Error while fetching label namespace values from Prometheus: " + err.Error() + status.Prometheus.ErrorCode = code + } else { + status.Prometheus.IsReady = true + status.Prometheus.NamespacesCount = len(promNamespaces) + } } - status.PromNamespacesCount = len(promNamespaces) } - if status.IsAllowLoki { + if status.Loki.IsEnabled { resp, code, err := h.getLokiStatus(r) if err != nil { - writeError(w, code, err.Error()) - return - } - lokiStatus := string(resp) - if strings.Contains(lokiStatus, "ready") { - status.IsLokiReady = true + status.Loki.Error = err.Error() + status.Loki.ErrorCode = code } else { - status.IsLokiReady = false - } + lokiStatus := string(resp) + status.Loki.IsReady = strings.Contains(lokiStatus, "ready") - lokiClients := newLokiClients(h.Cfg, r.Header, false) - // get namespaces using Loki - lokiNamespaces, code, err := h.getNamespacesValues(ctx, lokiClients, isDev) - if err != nil { - writeError(w, code, "Error while fetching label namespace values from Loki: "+err.Error()) - return + lokiClients := newLokiClients(h.Cfg, r.Header, false) + // get namespaces using Loki + lokiNamespaces, code, err := h.getNamespacesValues(ctx, lokiClients, isDev) + if err != nil { + status.Loki.Error = "Error while fetching label namespace values from Loki: " + err.Error() + status.Loki.ErrorCode = code + } else { + status.Loki.NamespacesCount = len(lokiNamespaces) + } } - status.LokiNamespacesCount = len(lokiNamespaces) - } - // consistent if both datasources are enabled and counts are equal - if status.IsAllowLoki && status.IsAllowProm { - status.IsConsistent = status.PromNamespacesCount == status.LokiNamespacesCount - } else { - status.IsConsistent = true } writeJSON(w, http.StatusOK, status) } diff --git a/web/locales/en/plugin__netobserv-plugin.json b/web/locales/en/plugin__netobserv-plugin.json index a88ddb093..f48ee3cdf 100644 --- a/web/locales/en/plugin__netobserv-plugin.json +++ b/web/locales/en/plugin__netobserv-plugin.json @@ -201,11 +201,15 @@ "More information": "More information", "Check your connectivity with cluster / console plugin pod": "Check your connectivity with cluster / console plugin pod", "Check current user permissions": "Check current user permissions", + "This deployment mode does not support non-admin users. Check FlowCollector spec.loki.manual.authToken": "This deployment mode does not support non-admin users. Check FlowCollector spec.loki.manual.authToken", "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", + "For metrics access, your user must either:": "For metrics access, your user must either:", + "have the 'netobserv-metrics-reader' namespace-scoped role": "have the 'netobserv-metrics-reader' namespace-scoped role", + "or for cluster-wide access, have the 'cluster-monitoring-view' cluster role": "or for cluster-wide access, have the 'cluster-monitoring-view' cluster role", "Configuring the Loki Operator": "Configuring the Loki Operator", "Configuring Grafana Loki (community)": "Configuring Grafana Loki (community)", "Show FlowCollector CR": "Show FlowCollector CR", @@ -216,10 +220,12 @@ "Show build info": "Show build info", "Show configuration limits": "Show configuration limits", "Check if Loki is running correctly. '/ready' endpoint should respond \"ready\"": "Check if Loki is running correctly. '/ready' endpoint should respond \"ready\"", + "Loki status check error: [{{code}}] {{err}}": "Loki status check error: [{{code}}] {{err}}", + "Prometheus status check error: [{{code}}] {{err}}": "Prometheus status check error: [{{code}}] {{err}}", "Can't find any namespace label in your Loki storage.": "Can't find any namespace label in your Loki storage.", "Can't find any namespace label in your Prometheus storage.": "Can't find any namespace label in your Prometheus storage.", "If this is the first time you run the operator, check FlowCollector status and health dashboard to ensure there is no error and flows are ingested. This can take some time.": "If this is the first time you run the operator, check FlowCollector status and health dashboard to ensure there is no error and flows are ingested. This can take some time.", - "Loki and Prom storages are not consistent. Check health dashboard for errors.": "Loki and Prom storages are not consistent. Check health dashboard for errors.", + "Loki and Prometheus storages are not consistent. Check health dashboard for errors.": "Loki and Prometheus storages are not consistent. Check health dashboard for errors.", "Zoom in / zoom out the histogram displayed time range. The selected time range in the top right corner will adapt accordingly. You can also use the plus or minus buttons while the histogram is focused.": "Zoom in / zoom out the histogram displayed time range. The selected time range in the top right corner will adapt accordingly. You can also use the plus or minus buttons while the histogram is focused.", "Move the selected range to filter the table below as time based pagination. You can also use the page up or down buttons while the histogram is focused.": "Move the selected range to filter the table below as time based pagination. You can also use the page up or down buttons while the histogram is focused.", "Shift the histogram displayed time range. The selected time range in the top right corner will adapt accordingly. You can also use the arrow left or right buttons while the histogram is focused.": "Shift the histogram displayed time range. The selected time range in the top right corner will adapt accordingly. You can also use the arrow left or right buttons while the histogram is focused.", diff --git a/web/src/api/loki.ts b/web/src/api/loki.ts index 9c6749520..5cd513f61 100644 --- a/web/src/api/loki.ts +++ b/web/src/api/loki.ts @@ -199,10 +199,14 @@ export const isValidTopologyMetrics = (metric: any): metric is TopologyMetrics = }; export interface Status { - isAllowProm: boolean; - promNamespacesCount: number; - isAllowLoki: boolean; - lokiNamespacesCount: number; - isLokiReady: boolean; - isConsistent: boolean; + loki: DatasourceStatus; + prometheus: DatasourceStatus; +} + +export interface DatasourceStatus { + isEnabled: boolean; + namespacesCount: number; + isReady: boolean; + error: string; + errorCode: number; } diff --git a/web/src/components/messages/error.tsx b/web/src/components/messages/error.tsx index add27904e..5f3830036 100644 --- a/web/src/components/messages/error.tsx +++ b/web/src/components/messages/error.tsx @@ -38,6 +38,8 @@ enum LokiInfo { export interface ErrorProps { title: string; error: string; + // TODO: (NETOBSERV-1877) refactor error type handling. + // "isLokiRelated" actually means here: "is neither prom-unsupported nor config loading error". But could actually be other than Loki related. isLokiRelated: boolean; } @@ -226,34 +228,59 @@ export const Error: React.FC = ({ title, error, isLokiRelated }) => {t(`Check your connectivity with cluster / console plugin pod`)} )} + {(error.includes('status code 401') || error.includes('status code 403')) && ( <> {t(`Check current user permissions`)} - {isLokiRelated && ( + {error.includes('user not an admin') ? ( + + {t( + `This deployment mode does not support non-admin users. Check FlowCollector spec.loki.manual.authToken` + )} + + ) : ( <> - - {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`)} - + {error.includes('from Loki') && ( + <> + + {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`)} + + + )} )} + {error.includes('from Prometheus') && ( + + {t(`For metrics access, your user must either:`)} + + + {t(`have the 'netobserv-metrics-reader' namespace-scoped role`)} + + + {t(`or for cluster-wide access, have the 'cluster-monitoring-view' cluster role`)} + + + + )} )} + {status && } {statusError && ( diff --git a/web/src/components/messages/status-texts.tsx b/web/src/components/messages/status-texts.tsx index 5f5b94876..9c2019509 100644 --- a/web/src/components/messages/status-texts.tsx +++ b/web/src/components/messages/status-texts.tsx @@ -12,22 +12,47 @@ export const StatusTexts: React.FC = ({ status }) => { return ( <> - {status && status.isAllowLoki && status.isLokiReady === false && ( - - {t(`Check if Loki is running correctly. '/ready' endpoint should respond "ready"`)} - + {status.loki.isEnabled && ( + <> + {status.loki.isReady === false && ( + + {t(`Check if Loki is running correctly. '/ready' endpoint should respond "ready"`)} + + )} + {status.loki.error && ( + + {t('Loki status check error: [{{code}}] {{err}}', { + err: status.loki.error, + code: status.loki.errorCode + })} + + )} + )} - {status && status.isAllowLoki && status.lokiNamespacesCount === 0 && ( + {status.prometheus.isEnabled && ( + <> + {status.prometheus.error && ( + + {t('Prometheus status check error: [{{code}}] {{err}}', { + err: status.prometheus.error, + code: status.prometheus.errorCode + })} + + )} + + )} + + {status.loki.isEnabled && !status.loki.error && status.loki.namespacesCount === 0 && ( {t(`Can't find any namespace label in your Loki storage.`)} )} - {status && status.isAllowProm && status.promNamespacesCount === 0 && ( + {status.prometheus.isEnabled && !status.prometheus.error && status.prometheus.namespacesCount === 0 && ( {t(`Can't find any namespace label in your Prometheus storage.`)} )} {status && - ((status.isAllowLoki && status.lokiNamespacesCount === 0) || - (status.isAllowProm && status.promNamespacesCount === 0)) && ( + ((status.loki.isEnabled && !status.loki.error && status.loki.namespacesCount === 0) || + (status.prometheus.isEnabled && !status.prometheus.error && status.prometheus.namespacesCount === 0)) && ( {t( // eslint-disable-next-line max-len @@ -36,12 +61,15 @@ export const StatusTexts: React.FC = ({ status }) => { )} {status && - status.isAllowLoki && - status.isLokiReady && - status.isAllowProm && - status.lokiNamespacesCount !== status.promNamespacesCount && ( + status.loki.isEnabled && + status.loki.isReady && + !status.loki.error && + status.prometheus.isEnabled && + status.prometheus.isReady && + !status.prometheus.error && + status.loki.namespacesCount !== status.prometheus.namespacesCount && ( - {t(`Loki and Prom storages are not consistent. Check health dashboard for errors.`)} + {t(`Loki and Prometheus storages are not consistent. Check health dashboard for errors.`)} )} diff --git a/web/src/model/filters.ts b/web/src/model/filters.ts index 46872b3b2..d6528ebe4 100644 --- a/web/src/model/filters.ts +++ b/web/src/model/filters.ts @@ -96,8 +96,8 @@ export const createFilterValue = (def: FilterDefinition, value: string): Promise return def.getOptions(value).then(opts => { const option = opts.find(opt => opt.name === value || opt.value === value); return option - ? { v: option.value, display: option.name } - : { v: value, display: value === undefinedValue ? 'n/a' : undefined }; + ? { v: option.value, display: option.name } + : { v: value, display: value === undefinedValue ? 'n/a' : undefined }; }); }; From 1cfb211a9038a18660b9a031517b9fe0b3473b36 Mon Sep 17 00:00:00 2001 From: Joel Takvorian Date: Tue, 17 Sep 2024 10:48:54 +0200 Subject: [PATCH 7/9] formatting --- web/src/components/messages/error.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/web/src/components/messages/error.tsx b/web/src/components/messages/error.tsx index 5f3830036..836b2ded4 100644 --- a/web/src/components/messages/error.tsx +++ b/web/src/components/messages/error.tsx @@ -269,9 +269,7 @@ export const Error: React.FC = ({ title, error, isLokiRelated }) => {t(`For metrics access, your user must either:`)} - - {t(`have the 'netobserv-metrics-reader' namespace-scoped role`)} - + {t(`have the 'netobserv-metrics-reader' namespace-scoped role`)} {t(`or for cluster-wide access, have the 'cluster-monitoring-view' cluster role`)} From b42aacae210a95fb9d82c354dc58ccbe3a1f819a Mon Sep 17 00:00:00 2001 From: Joel Takvorian Date: Tue, 17 Sep 2024 11:07:24 +0200 Subject: [PATCH 8/9] traffic drawer: reintroduce some lint checks --- .../drawer/netflow-traffic-drawer.tsx | 87 ++++++++++--------- 1 file changed, 48 insertions(+), 39 deletions(-) diff --git a/web/src/components/drawer/netflow-traffic-drawer.tsx b/web/src/components/drawer/netflow-traffic-drawer.tsx index 9098a4b22..2dbb26b7d 100644 --- a/web/src/components/drawer/netflow-traffic-drawer.tsx +++ b/web/src/components/drawer/netflow-traffic-drawer.tsx @@ -106,6 +106,22 @@ export const NetflowTrafficDrawer: React.FC = React.f const tableRef = React.useRef(null); const topologyRef = React.useRef(null); + const { + defaultFilters, + metrics, + resetDefaultFilters, + clearFilters, + filters, + topologyMetricFunction, + topologyMetricType, + setFilters, + match, + setShowQuerySummary, + clearSelections, + setSelectedRecord, + setSelectedElement + } = props; + React.useImperativeHandle(ref, () => ({ getOverviewHandle: () => overviewRef.current, getTableHandle: () => tableRef.current, @@ -114,97 +130,91 @@ export const NetflowTrafficDrawer: React.FC = React.f const onRecordSelect = React.useCallback( (record?: Record) => { - props.clearSelections(); - props.setSelectedRecord(record); + clearSelections(); + setSelectedRecord(record); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [props.clearSelections, props.setSelectedRecord] + [clearSelections, setSelectedRecord] ); const onElementSelect = React.useCallback( (element?: GraphElementPeer) => { - props.clearSelections(); - props.setSelectedElement(element); + clearSelections(); + setSelectedElement(element); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [props.clearSelections, props.setSelectedElement] + [clearSelections, setSelectedElement] ); const onToggleQuerySummary = React.useCallback( (v: boolean) => { - props.clearSelections(); - props.setShowQuerySummary(v); + clearSelections(); + setShowQuerySummary(v); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [props.clearSelections, props.setShowQuerySummary] + [clearSelections, setShowQuerySummary] ); const setFiltersList = React.useCallback( (list: Filter[]) => { - props.setFilters({ ...props.filters, list: list }); + setFilters({ ...filters, list: list }); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [props.setFilters] + [filters, setFilters] ); const getResetDefaultFiltersProp = React.useCallback(() => { - if (props.defaultFilters.length > 0 && !filtersEqual(props.filters.list, props.defaultFilters)) { - return props.resetDefaultFilters; + if (defaultFilters.length > 0 && !filtersEqual(filters.list, defaultFilters)) { + return resetDefaultFilters; } return undefined; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.defaultFilters, props.resetDefaultFilters, props.filters.list]); + }, [defaultFilters, resetDefaultFilters, filters.list]); const getClearFiltersProp = React.useCallback(() => { - if (props.filters.list.length > 0) { - return props.clearFilters; + if (filters.list.length > 0) { + return clearFilters; } return undefined; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.filters.list, props.clearFilters]); + }, [filters.list, clearFilters]); const getTopologyMetrics = React.useCallback(() => { - switch (props.topologyMetricType) { + switch (topologyMetricType) { case 'Bytes': case 'Packets': - return props.metrics.rateMetrics?.[getRateMetricKey(props.topologyMetricType)]; + return metrics.rateMetrics?.[getRateMetricKey(topologyMetricType)]; case 'DnsLatencyMs': - return props.metrics.dnsLatencyMetrics?.[getFunctionMetricKey(props.topologyMetricFunction)]; + return metrics.dnsLatencyMetrics?.[getFunctionMetricKey(topologyMetricFunction)]; case 'TimeFlowRttNs': - return props.metrics.rttMetrics?.[getFunctionMetricKey(props.topologyMetricFunction)]; + return metrics.rttMetrics?.[getFunctionMetricKey(topologyMetricFunction)]; default: return undefined; } }, [ - props.metrics.dnsLatencyMetrics, - props.topologyMetricFunction, - props.topologyMetricType, - props.metrics.rateMetrics, - props.metrics.rttMetrics + metrics.dnsLatencyMetrics, + topologyMetricFunction, + topologyMetricType, + metrics.rateMetrics, + metrics.rttMetrics ]); const getTopologyDroppedMetrics = React.useCallback(() => { - switch (props.topologyMetricType) { + switch (topologyMetricType) { case 'Bytes': case 'Packets': case 'PktDropBytes': case 'PktDropPackets': - return props.metrics.droppedRateMetrics?.[getRateMetricKey(props.topologyMetricType)]; + return metrics.droppedRateMetrics?.[getRateMetricKey(topologyMetricType)]; default: return undefined; } - }, [props.metrics.droppedRateMetrics, props.topologyMetricType]); + }, [metrics.droppedRateMetrics, topologyMetricType]); const checkSlownessReason = React.useCallback( (w: Warning | undefined): Warning | undefined => { if (w?.type == 'slow') { let reason = ''; - if (props.match === 'any' && hasNonIndexFields(props.filters.list)) { + if (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 (props.match === 'all' && !hasIndexFields(props.filters.list)) { + } else if (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' @@ -216,8 +226,7 @@ export const NetflowTrafficDrawer: React.FC = React.f } return w; }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [props.match, props.filters] + [match, filters] ); const mainContent = () => { From 55d93e09d179e0e6552b6a581b537c7d49d3e504 Mon Sep 17 00:00:00 2001 From: Julien Pinsonneau Date: Tue, 17 Sep 2024 14:37:16 +0200 Subject: [PATCH 9/9] format / lint --- web/src/model/filters.ts | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/web/src/model/filters.ts b/web/src/model/filters.ts index ed7888c44..598ad8e75 100644 --- a/web/src/model/filters.ts +++ b/web/src/model/filters.ts @@ -93,18 +93,21 @@ export interface FilterOption { } export const createFilterValue = (def: FilterDefinition, value: string): Promise => { - return def - .getOptions(value) - .then(opts => { - const option = opts.find(opt => opt.name === value || opt.value === value); - return option - ? { v: option.value, display: option.name } - : { v: value, display: value === undefinedValue ? 'n/a' : undefined }; - }) - .catch(_ => { - // In case of error, still create the minimal possible FilterValue - return { v: value }; - }); + return ( + def + .getOptions(value) + .then(opts => { + const option = opts.find(opt => opt.name === value || opt.value === value); + return option + ? { v: option.value, display: option.name } + : { v: value, display: value === undefinedValue ? 'n/a' : undefined }; + }) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .catch(_ => { + // In case of error, still create the minimal possible FilterValue + return { v: value }; + }) + ); }; export const hasEnabledFilterValues = (filter: Filter) => {