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..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 } @@ -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..7bcfa74ef 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, 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, 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, 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,35 +121,41 @@ 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()) - 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...) - - 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 { + resp, code, err := prometheus.GetLabelValues(ctx, client, 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) + if cl.loki != nil { + 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 +215,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/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..cdcaa8a89 100644 --- a/pkg/handler/status.go +++ b/pkg/handler/status.go @@ -1,14 +1,73 @@ 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 { + 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) { + return func(w http.ResponseWriter, r *http.Request) { + params := r.URL.Query() + namespace := params.Get(namespaceKey) + isDev := namespace != "" + + status := Status{ + Prometheus: DatasourceStatus{IsEnabled: h.Cfg.IsPromEnabled()}, + Loki: DatasourceStatus{IsEnabled: h.Cfg.IsLokiEnabled()}, + } + + if status.Prometheus.IsEnabled { + promClients, err := newPromClients(h.Cfg, r.Header, namespace) + if err != nil { + 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) + } + } + } + + if status.Loki.IsEnabled { + resp, code, err := h.getLokiStatus(r) + if err != nil { + status.Loki.Error = err.Error() + status.Loki.ErrorCode = code + } else { + 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 { + status.Loki.Error = "Error while fetching label namespace values from Loki: " + err.Error() + status.Loki.ErrorCode = code + } else { + status.Loki.NamespacesCount = len(lokiNamespaces) + } + } + } + writeJSON(w, http.StatusOK, status) } } 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 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/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..f48ee3cdf 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,20 +199,33 @@ "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", + "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", + "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\"", + "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 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.", @@ -323,8 +337,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 +391,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..5cd513f61 100644 --- a/web/src/api/loki.ts +++ b/web/src/api/loki.ts @@ -197,3 +197,16 @@ export const isValidTopologyMetrics = (metric: any): metric is TopologyMetrics = typeof metric.scope === 'string' ); }; + +export interface Status { + loki: DatasourceStatus; + prometheus: DatasourceStatus; +} + +export interface DatasourceStatus { + isEnabled: boolean; + namespacesCount: number; + isReady: boolean; + error: string; + errorCode: number; +} 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..2dbb26b7d 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'; @@ -107,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, @@ -115,104 +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 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 (defaultFilters.length > 0 && !filtersEqual(filters.list, defaultFilters)) { + return resetDefaultFilters; + } + return undefined; + }, [defaultFilters, resetDefaultFilters, filters.list]); + + const getClearFiltersProp = React.useCallback(() => { + if (filters.list.length > 0) { + return clearFilters; + } + return undefined; + }, [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' @@ -224,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 = () => { @@ -252,7 +253,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 +277,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 +306,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..836b2ded4 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'; @@ -35,19 +38,23 @@ 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; } 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 +65,7 @@ export const Error: React.FC = ({ title, error, isLokiRelated }) => setInfo(getHTTPErrorDetails(err)); }) .finally(() => { - setLoading(false); + setLokiLoading(false); }); break; case LokiInfo.Limits: @@ -69,7 +76,7 @@ export const Error: React.FC = ({ title, error, isLokiRelated }) => setInfo(getHTTPErrorDetails(err)); }) .finally(() => { - setLoading(false); + setLokiLoading(false); }); break; case LokiInfo.Metrics: @@ -80,14 +87,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 +106,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 +213,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,42 +223,70 @@ 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`)} )} + {(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`)} - + {error.includes('user not an admin') ? ( + + {t( + `This deployment mode does not support non-admin users. Check 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 && ( + + {t('Check for errors in health dashboard. Status endpoint is returning: {{statusError}}', { + statusError + })} + + )} } @@ -262,7 +303,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 +327,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..9c2019509 --- /dev/null +++ b/web/src/components/messages/status-texts.tsx @@ -0,0 +1,79 @@ +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.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.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.prometheus.isEnabled && !status.prometheus.error && status.prometheus.namespacesCount === 0 && ( + + {t(`Can't find any namespace label in your Prometheus storage.`)} + + )} + {status && + ((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 + `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.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 Prometheus 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..f6f747d30 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(); @@ -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]); 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/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..598ad8e75 100644 --- a/web/src/model/filters.ts +++ b/web/src/model/filters.ts @@ -93,12 +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 }; - }); + 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) => { 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); });