diff --git a/CHANGELOG.md b/CHANGELOG.md index 378c4bce7..d32500469 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ NOTE: As semantic versioning states all 0.y.z releases can contain breaking chan ### Added - [#177](https://github.com/kobsio/kobs/pull/177): [istio] Add Istio plugin to get an overview of all applications in the service mesh. +- [#182](https://github.com/kobsio/kobs/pull/182): [istio] Add top and tap commands and add details view for metrics. ### Fixed diff --git a/cmd/kobs/plugins/plugins.go b/cmd/kobs/plugins/plugins.go index dc4f9ccd0..757dab8cb 100644 --- a/cmd/kobs/plugins/plugins.go +++ b/cmd/kobs/plugins/plugins.go @@ -78,10 +78,10 @@ func Register(clusters *clusters.Clusters, config Config) chi.Router { dashboardsRouter := dashboards.Register(clusters, router.plugins, config.Dashboards) prometheusRouter, prometheusInstances := prometheus.Register(clusters, router.plugins, config.Prometheus) elasticsearchRouter := elasticsearch.Register(clusters, router.plugins, config.Elasticsearch) - clickhouseRouter := clickhouse.Register(clusters, router.plugins, config.Clickhouse) + clickhouseRouter, clickhouseInstances := clickhouse.Register(clusters, router.plugins, config.Clickhouse) jaegerRouter := jaeger.Register(clusters, router.plugins, config.Jaeger) kialiRouter := kiali.Register(clusters, router.plugins, config.Kiali) - istioRouter := istio.Register(clusters, router.plugins, config.Istio, prometheusInstances) + istioRouter := istio.Register(clusters, router.plugins, config.Istio, prometheusInstances, clickhouseInstances) fluxRouter := flux.Register(clusters, router.plugins, config.Flux) opsgenieRouter := opsgenie.Register(clusters, router.plugins, config.Opsgenie) sqlRouter := sql.Register(clusters, router.plugins, config.SQL) diff --git a/docs/resources/resources.md b/docs/resources/resources.md index 4a5e5d797..6aabe377c 100644 --- a/docs/resources/resources.md +++ b/docs/resources/resources.md @@ -18,7 +18,7 @@ If you want to view the Yaml representation of the resource you can select the c ![YAML](assets/resources-yaml.png) -Next to the yaml representation, you find a seconde tab events, which shows all events, which are related to the selected object. The events are retrieved with a field selector and the name of the resource: `fieldSelector=involvedObject.name=`. +Next to the yaml representation, you find a second tab events, which shows all events, which are related to the selected object. The events are retrieved with a field selector and the name of the resource: `fieldSelector=involvedObject.name=`. ![Events](assets/resources-events.png) diff --git a/plugins/applications/src/components/page/Page.tsx b/plugins/applications/src/components/page/Page.tsx index e45318172..80d23dfb5 100644 --- a/plugins/applications/src/components/page/Page.tsx +++ b/plugins/applications/src/components/page/Page.tsx @@ -7,7 +7,7 @@ import { IPluginPageProps } from '@kobsio/plugin-core'; // The page for the applications plugin, supports two different routes: One for showing a gallery / topology of // applications, where the user can select a list of clusters and namespaces for which he wants to get the applications -// and a seconde one for showing a single application. +// and a second one for showing a single application. const Page: React.FunctionComponent = ({ name, displayName, description }: IPluginPageProps) => { return ( diff --git a/plugins/clickhouse/clickhouse.go b/plugins/clickhouse/clickhouse.go index b1a21cfd7..684265ed7 100644 --- a/plugins/clickhouse/clickhouse.go +++ b/plugins/clickhouse/clickhouse.go @@ -106,7 +106,7 @@ func (router *Router) getLogs(w http.ResponseWriter, r *http.Request) { done <- true }() - documents, fields, count, took, buckets, err := i.GetLogs(r.Context(), query, order, orderBy, parsedTimeStart, parsedTimeEnd) + documents, fields, count, took, buckets, err := i.GetLogs(r.Context(), query, order, orderBy, 1000, parsedTimeStart, parsedTimeEnd) if err != nil { errresponse.Render(w, r, err, http.StatusBadRequest, "Could not get logs") return @@ -207,7 +207,7 @@ func (router *Router) getAggregation(w http.ResponseWriter, r *http.Request) { } // Register returns a new router which can be used in the router for the kobs rest api. -func Register(clusters *clusters.Clusters, plugins *plugin.Plugins, config Config) chi.Router { +func Register(clusters *clusters.Clusters, plugins *plugin.Plugins, config Config) (chi.Router, []*instance.Instance) { var instances []*instance.Instance for _, cfg := range config { @@ -235,5 +235,5 @@ func Register(clusters *clusters.Clusters, plugins *plugin.Plugins, config Confi router.Get("/logs/{name}", router.getLogs) router.Get("/aggregation/{name}", router.getAggregation) - return router + return router, instances } diff --git a/plugins/clickhouse/pkg/instance/instance.go b/plugins/clickhouse/pkg/instance/instance.go index 16ed236c9..5aa1b1ed4 100644 --- a/plugins/clickhouse/pkg/instance/instance.go +++ b/plugins/clickhouse/pkg/instance/instance.go @@ -39,7 +39,7 @@ type Instance struct { // GetLogs parses the given query into the sql syntax, which is then run against the ClickHouse instance. The returned // rows are converted into a document schema which can be used by our UI. -func (i *Instance) GetLogs(ctx context.Context, query, order, orderBy string, timeStart, timeEnd int64) ([]map[string]interface{}, []string, int64, int64, []Bucket, error) { +func (i *Instance) GetLogs(ctx context.Context, query, order, orderBy string, limit, timeStart, timeEnd int64) ([]map[string]interface{}, []string, int64, int64, []Bucket, error) { var count int64 var buckets []Bucket var documents []map[string]interface{} @@ -120,7 +120,7 @@ func (i *Instance) GetLogs(ctx context.Context, query, order, orderBy string, ti // start date. In these follow up calls the start time isn't changed again, because we are skipping the count // and bucket queries. for i := len(buckets) - 1; i >= 0; i-- { - if count < 1000 && buckets[i].Count > 0 { + if count < limit && buckets[i].Count > 0 { if timeConditions == "" { timeConditions = fmt.Sprintf("(timestamp >= FROM_UNIXTIME(%d) AND timestamp <= FROM_UNIXTIME(%d))", buckets[i].Interval, buckets[i].Interval+interval) } else { @@ -144,7 +144,7 @@ func (i *Instance) GetLogs(ctx context.Context, query, order, orderBy string, ti // Now we are building and executing our sql query. We always return all fields from the logs table, where the // timestamp of a row is within the selected query range and the parsed query. We also order all the results by the // timestamp field and limiting the results / using a offset for pagination. - sqlQueryRawLogs := fmt.Sprintf("SELECT %s FROM %s.logs WHERE (%s) %s ORDER BY %s LIMIT 1000 SETTINGS skip_unavailable_shards = 1", defaultColumns, i.database, timeConditions, conditions, parsedOrder) + sqlQueryRawLogs := fmt.Sprintf("SELECT %s FROM %s.logs WHERE (%s) %s ORDER BY %s LIMIT %d SETTINGS skip_unavailable_shards = 1", defaultColumns, i.database, timeConditions, conditions, parsedOrder, limit) log.WithFields(logrus.Fields{"query": sqlQueryRawLogs}).Tracef("sql query raw logs") rowsRawLogs, err := i.client.QueryContext(ctx, sqlQueryRawLogs) if err != nil { @@ -245,6 +245,45 @@ func (i *Instance) GetAggregation(ctx context.Context, limit int64, groupBy, ope return data, nil } +// GetRawQueryResults returns all rows for the user provided SQL query. This function should only be used by other +// plugins. If users should be able to directly access a Clickhouse instance you can expose the instance using the SQL +// plugin. +func (i *Instance) GetRawQueryResults(ctx context.Context, query string) ([][]interface{}, []string, error) { + log.WithFields(logrus.Fields{"query": query}).Tracef("raw sql query") + + rows, err := i.client.QueryContext(ctx, query) + if err != nil { + return nil, nil, err + } + defer rows.Close() + + var columns []string + columns, err = rows.Columns() + if err != nil { + return nil, nil, err + } + columnsLen := len(columns) + + var result [][]interface{} + + for rows.Next() { + var r []interface{} + r = make([]interface{}, columnsLen) + + for i := 0; i < columnsLen; i++ { + r[i] = new(interface{}) + } + + if err := rows.Scan(r...); err != nil { + return nil, nil, err + } + + result = append(result, r) + } + + return result, columns, nil +} + // New returns a new ClickHouse instance for the given configuration. func New(config Config) (*Instance, error) { if config.WriteTimeout == "" { diff --git a/plugins/clickhouse/pkg/instance/logs.go b/plugins/clickhouse/pkg/instance/logs.go index d6346e3ed..c32f694ad 100644 --- a/plugins/clickhouse/pkg/instance/logs.go +++ b/plugins/clickhouse/pkg/instance/logs.go @@ -99,16 +99,16 @@ func splitOperator(condition string, materializedColumns []string) (string, erro return handleConditionParts(notIlike[0], notIlike[1], "!~", materializedColumns) } - equal := strings.Split(condition, "=") - if len(equal) == 2 { - return handleConditionParts(equal[0], equal[1], "=", materializedColumns) - } - regex := strings.Split(condition, "~") if len(regex) == 2 { return handleConditionParts(regex[0], regex[1], "~", materializedColumns) } + equal := strings.Split(condition, "=") + if len(equal) == 2 { + return handleConditionParts(equal[0], equal[1], "=", materializedColumns) + } + if strings.Contains(condition, "_exists_ ") { return handleExistsCondition(strings.TrimLeft(strings.TrimSpace(condition), "_exists_ "), materializedColumns), nil } diff --git a/plugins/flux/src/components/page/PageList.tsx b/plugins/flux/src/components/page/PageList.tsx index 28818c3c5..5f36a31bf 100644 --- a/plugins/flux/src/components/page/PageList.tsx +++ b/plugins/flux/src/components/page/PageList.tsx @@ -57,7 +57,7 @@ const PageList: React.FunctionComponent = ({ }, ); - // refetchhWithDelay is used to call the refetch function to get the resource, but with a delay of 3 seconde. This is + // refetchhWithDelay is used to call the refetch function to get the resource, but with a delay of 3 seconds. This is // required, because sometime the Kubenretes isn't that fast after an action (edit, delete, ...) was triggered. const refetchhWithDelay = (): void => { setTimeout(() => { diff --git a/plugins/flux/src/components/panel/PanelList.tsx b/plugins/flux/src/components/panel/PanelList.tsx index 525619d91..5caedb2d9 100644 --- a/plugins/flux/src/components/panel/PanelList.tsx +++ b/plugins/flux/src/components/panel/PanelList.tsx @@ -62,7 +62,7 @@ const PanelList: React.FunctionComponent = ({ }, ); - // refetchhWithDelay is used to call the refetch function to get the resource, but with a delay of 3 seconde. This is + // refetchhWithDelay is used to call the refetch function to get the resource, but with a delay of 3 seconds. This is // required, because sometime the Kubenretes isn't that fast after an action (edit, delete, ...) was triggered. const refetchhWithDelay = (): void => { setTimeout(() => { diff --git a/plugins/istio/istio.go b/plugins/istio/istio.go index 38a8f07fe..cbe30a532 100644 --- a/plugins/istio/istio.go +++ b/plugins/istio/istio.go @@ -7,6 +7,7 @@ import ( "github.com/kobsio/kobs/pkg/api/clusters" "github.com/kobsio/kobs/pkg/api/middleware/errresponse" "github.com/kobsio/kobs/pkg/api/plugins/plugin" + clickhouseInstance "github.com/kobsio/kobs/plugins/clickhouse/pkg/instance" "github.com/kobsio/kobs/plugins/istio/pkg/instance" prometheusInstance "github.com/kobsio/kobs/plugins/prometheus/pkg/instance" @@ -119,6 +120,88 @@ func (router *Router) getMetrics(w http.ResponseWriter, r *http.Request) { render.JSON(w, r, metrics) } +func (router *Router) getMetricsDetails(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + timeStart := r.URL.Query().Get("timeStart") + timeEnd := r.URL.Query().Get("timeEnd") + metric := r.URL.Query().Get("metric") + reporter := r.URL.Query().Get("reporter") + destinationWorkload := r.URL.Query().Get("destinationWorkload") + destinationWorkloadNamespace := r.URL.Query().Get("destinationWorkloadNamespace") + destinationVersion := r.URL.Query().Get("destinationVersion") + destinationService := r.URL.Query().Get("destinationService") + sourceWorkload := r.URL.Query().Get("sourceWorkload") + sourceWorkloadNamespace := r.URL.Query().Get("sourceWorkloadNamespace") + pod := r.URL.Query().Get("pod") + + log.WithFields(logrus.Fields{"name": name, "timeEnd": timeEnd, "timeStart": timeStart, "metric": metric, "reporter": reporter, "destinationWorkload": destinationWorkload, "destinationWorkloadNamespace": destinationWorkloadNamespace, "destinationVersion": destinationVersion, "destinationService": destinationService, "sourceWorkload": sourceWorkload, "sourceWorkloadNamespace": sourceWorkloadNamespace, "pod": pod}).Tracef("getMetricsDetails") + + i := router.getInstance(name) + if i == nil { + errresponse.Render(w, r, nil, http.StatusBadRequest, "Could not find instance name") + return + } + + parsedTimeStart, err := strconv.ParseInt(timeStart, 10, 64) + if err != nil { + errresponse.Render(w, r, err, http.StatusBadRequest, "Could not parse start time") + return + } + + parsedTimeEnd, err := strconv.ParseInt(timeEnd, 10, 64) + if err != nil { + errresponse.Render(w, r, err, http.StatusBadRequest, "Could not parse end time") + return + } + + metrics, err := i.GetMetricsDetails(r.Context(), metric, reporter, destinationWorkload, destinationWorkloadNamespace, destinationVersion, destinationService, sourceWorkload, sourceWorkloadNamespace, pod, parsedTimeStart, parsedTimeEnd) + if err != nil { + errresponse.Render(w, r, err, http.StatusBadRequest, "Could not get metrics") + return + } + + log.Tracef("getMetricsDetails") + render.JSON(w, r, metrics) +} + +func (router *Router) getMetricsPod(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + timeStart := r.URL.Query().Get("timeStart") + timeEnd := r.URL.Query().Get("timeEnd") + metric := r.URL.Query().Get("metric") + namespace := r.URL.Query().Get("namespace") + pod := r.URL.Query().Get("pod") + + log.WithFields(logrus.Fields{"name": name, "timeEnd": timeEnd, "timeStart": timeStart, "metric": metric, "namespace": namespace, "pod": pod}).Tracef("getMetricsPod") + + i := router.getInstance(name) + if i == nil { + errresponse.Render(w, r, nil, http.StatusBadRequest, "Could not find instance name") + return + } + + parsedTimeStart, err := strconv.ParseInt(timeStart, 10, 64) + if err != nil { + errresponse.Render(w, r, err, http.StatusBadRequest, "Could not parse start time") + return + } + + parsedTimeEnd, err := strconv.ParseInt(timeEnd, 10, 64) + if err != nil { + errresponse.Render(w, r, err, http.StatusBadRequest, "Could not parse end time") + return + } + + metrics, err := i.GetMetricsPod(r.Context(), metric, namespace, pod, parsedTimeStart, parsedTimeEnd) + if err != nil { + errresponse.Render(w, r, err, http.StatusBadRequest, "Could not get metrics") + return + } + + log.Tracef("getMetricsPod") + render.JSON(w, r, metrics) +} + func (router *Router) getTopology(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") timeStart := r.URL.Query().Get("timeStart") @@ -164,12 +247,135 @@ func (router *Router) getTopology(w http.ResponseWriter, r *http.Request) { render.JSON(w, r, data) } +func (router *Router) getTap(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + timeStart := r.URL.Query().Get("timeStart") + timeEnd := r.URL.Query().Get("timeEnd") + application := r.URL.Query().Get("application") + namespace := r.URL.Query().Get("namespace") + filterName := r.URL.Query().Get("filterName") + filterMethod := r.URL.Query().Get("filterMethod") + filterPath := r.URL.Query().Get("filterPath") + + log.WithFields(logrus.Fields{"name": name, "timeStart": timeStart, "timeEnd": timeEnd, "application": application, "namespace": namespace, "filterName": filterName, "filterMethod": filterMethod, "filterPath": filterPath}).Tracef("getTap") + + i := router.getInstance(name) + if i == nil { + errresponse.Render(w, r, nil, http.StatusBadRequest, "Could not find instance name") + return + } + + parsedTimeStart, err := strconv.ParseInt(timeStart, 10, 64) + if err != nil { + errresponse.Render(w, r, err, http.StatusBadRequest, "Could not parse start time") + return + } + + parsedTimeEnd, err := strconv.ParseInt(timeEnd, 10, 64) + if err != nil { + errresponse.Render(w, r, err, http.StatusBadRequest, "Could not parse end time") + return + } + + logs, err := i.Tap(r.Context(), namespace, application, filterName, filterMethod, filterPath, parsedTimeStart, parsedTimeEnd) + if err != nil { + errresponse.Render(w, r, err, http.StatusBadRequest, "Could not get logs") + return + } + + log.WithFields(logrus.Fields{"logs": len(logs)}).Tracef("getTap") + render.JSON(w, r, logs) +} + +func (router *Router) getTop(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + timeStart := r.URL.Query().Get("timeStart") + timeEnd := r.URL.Query().Get("timeEnd") + application := r.URL.Query().Get("application") + namespace := r.URL.Query().Get("namespace") + filterName := r.URL.Query().Get("filterName") + filterMethod := r.URL.Query().Get("filterMethod") + filterPath := r.URL.Query().Get("filterPath") + sortBy := r.URL.Query().Get("sortBy") + sortDirection := r.URL.Query().Get("sortDirection") + + log.WithFields(logrus.Fields{"name": name, "timeStart": timeStart, "timeEnd": timeEnd, "application": application, "namespace": namespace, "filterName": filterName, "filterMethod": filterMethod, "filterPath": filterPath}).Tracef("getTop") + + i := router.getInstance(name) + if i == nil { + errresponse.Render(w, r, nil, http.StatusBadRequest, "Could not find instance name") + return + } + + parsedTimeStart, err := strconv.ParseInt(timeStart, 10, 64) + if err != nil { + errresponse.Render(w, r, err, http.StatusBadRequest, "Could not parse start time") + return + } + + parsedTimeEnd, err := strconv.ParseInt(timeEnd, 10, 64) + if err != nil { + errresponse.Render(w, r, err, http.StatusBadRequest, "Could not parse end time") + return + } + + logs, err := i.Top(r.Context(), namespace, application, filterName, filterMethod, filterPath, sortBy, sortDirection, parsedTimeStart, parsedTimeEnd) + if err != nil { + errresponse.Render(w, r, err, http.StatusBadRequest, "Could not get logs") + return + } + + log.WithFields(logrus.Fields{"logs": len(logs)}).Tracef("getTop") + render.JSON(w, r, logs) +} + +func (router *Router) getTopDetails(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + timeStart := r.URL.Query().Get("timeStart") + timeEnd := r.URL.Query().Get("timeEnd") + application := r.URL.Query().Get("application") + namespace := r.URL.Query().Get("namespace") + upstreamCluster := r.URL.Query().Get("upstreamCluster") + authority := r.URL.Query().Get("authority") + method := r.URL.Query().Get("method") + path := r.URL.Query().Get("path") + + log.WithFields(logrus.Fields{"name": name, "timeStart": timeStart, "timeEnd": timeEnd, "application": application, "namespace": namespace, "upstreamCluster": upstreamCluster, "authority": authority, "method": method, "path": path}).Tracef("getTopDetails") + + i := router.getInstance(name) + if i == nil { + errresponse.Render(w, r, nil, http.StatusBadRequest, "Could not find instance name") + return + } + + parsedTimeStart, err := strconv.ParseInt(timeStart, 10, 64) + if err != nil { + errresponse.Render(w, r, err, http.StatusBadRequest, "Could not parse start time") + return + } + + parsedTimeEnd, err := strconv.ParseInt(timeEnd, 10, 64) + if err != nil { + errresponse.Render(w, r, err, http.StatusBadRequest, "Could not parse end time") + return + } + + metrics, err := i.TopDetails(r.Context(), namespace, application, upstreamCluster, authority, method, path, parsedTimeStart, parsedTimeEnd) + if err != nil { + errresponse.Render(w, r, err, http.StatusBadRequest, "Could not get success rate") + return + } + + log.Tracef("getTopDetails") + render.JSON(w, r, metrics) +} + // Register returns a new router which can be used in the router for the kobs rest api. -func Register(clusters *clusters.Clusters, plugins *plugin.Plugins, config Config, prometheusInstances []*prometheusInstance.Instance) chi.Router { +func Register(clusters *clusters.Clusters, plugins *plugin.Plugins, config Config, prometheusInstances []*prometheusInstance.Instance, clickhouseInstances []*clickhouseInstance.Instance) chi.Router { var instances []*instance.Instance for _, cfg := range config { - instance, err := instance.New(cfg, prometheusInstances) + instance, err := instance.New(cfg, prometheusInstances, clickhouseInstances) if err != nil { log.WithError(err).WithFields(logrus.Fields{"name": cfg.Name}).Fatalf("Could not create Istio instance") } @@ -179,6 +385,7 @@ func Register(clusters *clusters.Clusters, plugins *plugin.Plugins, config Confi var options map[string]interface{} options = make(map[string]interface{}) options["prometheus"] = cfg.Prometheus.Enabled + options["clickhouse"] = cfg.Clickhouse.Enabled plugins.Append(plugin.Plugin{ Name: cfg.Name, @@ -197,7 +404,12 @@ func Register(clusters *clusters.Clusters, plugins *plugin.Plugins, config Confi router.Get("/namespaces/{name}", router.getNamespaces) router.Get("/metrics/{name}", router.getMetrics) + router.Get("/metricsdetails/{name}", router.getMetricsDetails) + router.Get("/metricspod/{name}", router.getMetricsPod) router.Get("/topology/{name}", router.getTopology) + router.Get("/tap/{name}", router.getTap) + router.Get("/top/{name}", router.getTop) + router.Get("/topdetails/{name}", router.getTopDetails) return router } diff --git a/plugins/istio/package.json b/plugins/istio/package.json index f7adb08dc..646aafc5c 100644 --- a/plugins/istio/package.json +++ b/plugins/istio/package.json @@ -12,11 +12,18 @@ "dependencies": { "@kobsio/plugin-core": "*", "@kobsio/plugin-prometheus": "*", + "@nivo/line": "^0.74.0", "@patternfly/react-core": "^4.128.2", + "@types/cytoscape": "^3.14.15", "@types/react": "^17.0.0", + "@types/react-cytoscapejs": "^1.2.2", "@types/react-dom": "^17.0.0", "@types/react-router-dom": "^5.1.7", + "cytoscape": "^3.19.1", + "cytoscape-dagre": "^2.3.2", + "cytoscape-node-html-label": "^1.2.2", "react": "^17.0.2", + "react-cytoscapejs": "^1.2.1", "react-dom": "^17.0.2", "react-query": "^3.17.2", "react-router-dom": "^5.2.0", diff --git a/plugins/istio/pkg/instance/instance.go b/plugins/istio/pkg/instance/instance.go index 1e32edd82..e0e5d9cd1 100644 --- a/plugins/istio/pkg/instance/instance.go +++ b/plugins/istio/pkg/instance/instance.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + clickhouseInstance "github.com/kobsio/kobs/plugins/clickhouse/pkg/instance" prometheusInstance "github.com/kobsio/kobs/plugins/prometheus/pkg/instance" "github.com/sirupsen/logrus" @@ -20,6 +21,7 @@ type Config struct { DisplayName string `json:"displayName"` Description string `json:"description"` Prometheus ConfigPrometheus `json:"prometheus"` + Clickhouse ConfigClickhouse `json:"clickhouse"` } // ConfigPrometheus is the structure of the configuration, which is required to enabled the Prometheus integration for @@ -29,10 +31,18 @@ type ConfigPrometheus struct { Name string `json:"name"` } +// ConfigClickhouse is the structure of the configuration, which is required to enabled the Clickhouse integration for +// the Istio plugin. +type ConfigClickhouse struct { + Enabled bool `json:"enabled"` + Name string `json:"name"` +} + // Instance represents a single Jaeger instance, which can be added via the configuration file. type Instance struct { Name string prometheus *prometheusInstance.Instance + clickhouse *clickhouseInstance.Instance } // GetNamespaces returns a list of namespaces, which can be selected to get the applications from. @@ -76,6 +86,82 @@ func (i *Instance) GetMetrics(ctx context.Context, namespaces []string, applicat return i.prometheus.GetTableData(ctx, queries, timeEnd) } +// GetMetricsDetails returns the timeseries data for the requested details metric. +func (i *Instance) GetMetricsDetails(ctx context.Context, metric, reporter, destinationWorkload, destinationWorkloadNamespace, destinationVersion, destinationService, sourceWorkload, sourceWorkloadNamespace, pod string, timeStart int64, timeEnd int64) (*prometheusInstance.Metrics, error) { + var queries []prometheusInstance.Query + + if metric == "sr" { + queries = append(queries, prometheusInstance.Query{ + Label: "SR", + Query: fmt.Sprintf(`sum(irate(istio_requests_total{reporter="%s",destination_workload=~"%s",destination_workload_namespace=~"%s",destination_version=~"%s",destination_service=~"%s",source_workload=~"%s",source_workload_namespace=~"%s",pod=~"%s",response_code!~"5.*"}[5m])) / sum(irate(istio_requests_total{reporter="%s",destination_workload=~"%s",destination_workload_namespace=~"%s",destination_version=~"%s",destination_service=~"%s",source_workload=~"%s",source_workload_namespace=~"%s",pod=~"%s"}[5m])) * 100`, reporter, destinationWorkload, destinationWorkloadNamespace, destinationVersion, destinationService, sourceWorkload, sourceWorkloadNamespace, pod, reporter, destinationWorkload, destinationWorkloadNamespace, destinationVersion, destinationService, sourceWorkload, sourceWorkloadNamespace, pod), + }) + } else if metric == "rps" { + queries = append(queries, prometheusInstance.Query{ + Label: "RPS", + Query: fmt.Sprintf(`round(sum(irate(istio_requests_total{reporter="%s",destination_workload=~"%s",destination_workload_namespace=~"%s",destination_version=~"%s",destination_service=~"%s",source_workload=~"%s",source_workload_namespace=~"%s",pod=~"%s"}[5m])), 0.001)`, reporter, destinationWorkload, destinationWorkloadNamespace, destinationVersion, destinationService, sourceWorkload, sourceWorkloadNamespace, pod), + }) + } else if metric == "latency" { + queries = append(queries, prometheusInstance.Query{ + Label: "P50", + Query: fmt.Sprintf(`histogram_quantile(0.50, sum(irate(istio_request_duration_milliseconds_bucket{reporter="%s",destination_workload=~"%s",destination_workload_namespace=~"%s",destination_version=~"%s",destination_service=~"%s",source_workload=~"%s",source_workload_namespace=~"%s",pod=~"%s"}[1m])) by (le))`, reporter, destinationWorkload, destinationWorkloadNamespace, destinationVersion, destinationService, sourceWorkload, sourceWorkloadNamespace, pod), + }) + queries = append(queries, prometheusInstance.Query{ + Label: "P90", + Query: fmt.Sprintf(`histogram_quantile(0.90, sum(irate(istio_request_duration_milliseconds_bucket{reporter="%s",destination_workload=~"%s",destination_workload_namespace=~"%s",destination_version=~"%s",destination_service=~"%s",source_workload=~"%s",source_workload_namespace=~"%s",pod=~"%s"}[1m])) by (le))`, reporter, destinationWorkload, destinationWorkloadNamespace, destinationVersion, destinationService, sourceWorkload, sourceWorkloadNamespace, pod), + }) + queries = append(queries, prometheusInstance.Query{ + Label: "P99", + Query: fmt.Sprintf(`histogram_quantile(0.99, sum(irate(istio_request_duration_milliseconds_bucket{reporter="%s",destination_workload=~"%s",destination_workload_namespace=~"%s",destination_version=~"%s",destination_service=~"%s",source_workload=~"%s",source_workload_namespace=~"%s",pod=~"%s"}[1m])) by (le))`, reporter, destinationWorkload, destinationWorkloadNamespace, destinationVersion, destinationService, sourceWorkload, sourceWorkloadNamespace, pod), + }) + } else { + return nil, fmt.Errorf("invalid metric") + } + + return i.prometheus.GetMetrics(ctx, queries, "", timeStart, timeEnd) +} + +// GetMetricsPod returns the timeseries data for the requested details metric. +func (i *Instance) GetMetricsPod(ctx context.Context, metric, namespace, pod string, timeStart int64, timeEnd int64) (*prometheusInstance.Metrics, error) { + var queries []prometheusInstance.Query + + if metric == "cpu" { + queries = append(queries, prometheusInstance.Query{ + Label: "Usage: {% .container %}", + Query: fmt.Sprintf(`sum(rate(container_cpu_usage_seconds_total{namespace="%s", image!="", pod=~"%s", container!="POD", container!=""}[2m])) by (container)`, namespace, pod), + }) + queries = append(queries, prometheusInstance.Query{ + Label: "Request: {% .container %}", + Query: fmt.Sprintf(`sum(kube_pod_container_resource_requests{namespace="%s", resource="cpu", pod=~"%s", container!="POD", container!=""}) by (container)`, namespace, pod), + }) + queries = append(queries, prometheusInstance.Query{ + Label: "Limits: {% .container %}", + Query: fmt.Sprintf(`sum(kube_pod_container_resource_limits{namespace="%s", resource="cpu", pod=~"%s", container!="POD", container!=""}) by (container)`, namespace, pod), + }) + } else if metric == "throttling" { + queries = append(queries, prometheusInstance.Query{ + Label: "{% .container %}", + Query: fmt.Sprintf(`sum(increase(container_cpu_cfs_throttled_periods_total{namespace="%s", pod="%s", container!="POD", container!=""}[5m])) by (container) /sum(increase(container_cpu_cfs_periods_total{namespace="%s", pod="%s", container!="POD", container!=""}[5m])) by (container) * 100`, namespace, pod, namespace, pod), + }) + } else if metric == "memory" { + queries = append(queries, prometheusInstance.Query{ + Label: "Usage: {% .container %}", + Query: fmt.Sprintf(`sum(rate(container_memory_working_set_bytes{namespace="%s", image!="", pod=~"%s", container!="POD", container!=""}[2m])) by (container) / 1024 / 1024`, namespace, pod), + }) + queries = append(queries, prometheusInstance.Query{ + Label: "Request: {% .container %}", + Query: fmt.Sprintf(`sum(kube_pod_container_resource_requests{namespace="%s", resource="memory", pod=~"%s", container!="POD", container!=""}) by (container) / 1024 / 1024`, namespace, pod), + }) + queries = append(queries, prometheusInstance.Query{ + Label: "Limits: {% .container %}", + Query: fmt.Sprintf(`sum(kube_pod_container_resource_limits{namespace="%s", resource="memory", pod=~"%s", container!="POD", container!=""}) by (container) / 1024 / 1024`, namespace, pod), + }) + } else { + return nil, fmt.Errorf("invalid metric") + } + + return i.prometheus.GetMetrics(ctx, queries, "", timeStart, timeEnd) +} + // GetTopology creates a simple topology graph for the given application, with all the incoming sources and outgoing // destinations for the application. func (i *Instance) GetTopology(ctx context.Context, namespace, application string, timeStart int64, timeEnd int64) ([]Edge, []Node, error) { @@ -214,9 +300,110 @@ func (i *Instance) GetTopology(ctx context.Context, namespace, application strin return edges, nodes, nil } +// Tap returns the logs for the specified Istio application. +func (i *Instance) Tap(ctx context.Context, namespace, application, filterName, filterMethod, filterPath string, timeStart int64, timeEnd int64) ([]map[string]interface{}, error) { + var filters string + if filterName != "" { + filters = filters + fmt.Sprintf(" _and_ content.authority~'%s'", filterName) + } + if filterMethod != "" { + filters = filters + fmt.Sprintf(" _and_ content.method~'%s'", filterMethod) + } + if filterPath != "" { + filters = filters + fmt.Sprintf(" _and_ content.path~'%s'", filterPath) + } + + logs, _, _, _, _, err := i.clickhouse.GetLogs(ctx, fmt.Sprintf("namespace='%s' _and_ app='%s' _and_ container_name='istio-proxy' %s", namespace, application, filters), "", "", 100, timeStart, timeEnd) + if err != nil { + return nil, err + } + + return logs, nil +} + +// Top returns the aggregated logs for the specified Istio application. +func (i *Instance) Top(ctx context.Context, namespace, application, filterName, filterMethod, filterPath, sortBy, sortDirection string, timeStart int64, timeEnd int64) ([][]interface{}, error) { + var filters string + if filterName != "" { + filters = filters + fmt.Sprintf(" AND match(fields_string.value[indexOf(fields_string.key, 'content.authority')], '%s')", filterName) + } + if filterMethod != "" { + filters = filters + fmt.Sprintf(" AND match(fields_string.value[indexOf(fields_string.key, 'content.method')], '%s')", filterMethod) + } + if filterPath != "" { + filters = filters + fmt.Sprintf(" AND match(fields_string.value[indexOf(fields_string.key, 'content.path')], '%s')", filterPath) + } + + rows, _, err := i.clickhouse.GetRawQueryResults(ctx, fmt.Sprintf(`SELECT + fields_string.value[indexOf(fields_string.key, 'content.upstream_cluster')] as upstream, + fields_string.value[indexOf(fields_string.key, 'content.authority')] as name, + fields_string.value[indexOf(fields_string.key, 'content.method')] as method, + fields_string.value[indexOf(fields_string.key, 'content.path')] as path, + count(*) as count, + min(fields_number.value[indexOf(fields_number.key, 'content.duration')]) as min, + max(fields_number.value[indexOf(fields_number.key, 'content.duration')]) as max, + avg(fields_number.value[indexOf(fields_number.key, 'content.duration')]) as avg, + anyLast(fields_number.value[indexOf(fields_number.key, 'content.duration')]) as last, + countIf(fields_number.value[indexOf(fields_number.key, 'content.response_code')] < 500) / count * 100 as sr +FROM logs.logs +WHERE timestamp >= FROM_UNIXTIME(%d) AND timestamp <= FROM_UNIXTIME(%d) AND namespace = '%s' AND app = '%s' AND container_name = 'istio-proxy' %s +GROUP BY + fields_string.value[indexOf(fields_string.key, 'content.upstream_cluster')], + fields_string.value[indexOf(fields_string.key, 'content.authority')], + fields_string.value[indexOf(fields_string.key, 'content.method')], + fields_string.value[indexOf(fields_string.key, 'content.path')] +ORDER BY %s %s +LIMIT 100 +SETTINGS skip_unavailable_shards = 1`, timeStart, timeEnd, namespace, application, filters, sortBy, sortDirection)) + if err != nil { + return nil, err + } + + return rows, nil +} + +// TopDetails returns the success rate and latency for the specified upstream cluster. authority, method and path. +func (i *Instance) TopDetails(ctx context.Context, namespace, application, upstreamCluster, authority, method, path string, timeStart int64, timeEnd int64) ([][]interface{}, error) { + interval := (timeEnd - timeStart) / 30 + filters := fmt.Sprintf(" AND namespace = '%s' AND app = '%s' AND container_name = 'istio-proxy'", namespace, application) + + if upstreamCluster != "" { + filters = filters + fmt.Sprintf(" AND fields_string.value[indexOf(fields_string.key, 'content.upstream_cluster')] = '%s'", upstreamCluster) + } + if authority != "" { + filters = filters + fmt.Sprintf(" AND fields_string.value[indexOf(fields_string.key, 'content.authority')] = '%s'", authority) + } + if method != "" { + filters = filters + fmt.Sprintf(" AND fields_string.value[indexOf(fields_string.key, 'content.method')] = '%s'", method) + } + if path != "" { + filters = filters + fmt.Sprintf(" AND fields_string.value[indexOf(fields_string.key, 'content.path')] = '%s'", path) + } + + rows, _, err := i.clickhouse.GetRawQueryResults(ctx, fmt.Sprintf(`SELECT + toStartOfInterval(timestamp, INTERVAL %d second) AS interval_data, + count(*) AS count_data, + countIf(fields_number.value[indexOf(fields_number.key, 'content.response_code')] < 500) / count_data * 100 as sr_data, + quantile(0.5)(fields_number.value[indexOf(fields_number.key, 'content.duration')]) as p50_data, + quantile(0.9)(fields_number.value[indexOf(fields_number.key, 'content.duration')]) as p90_data, + quantile(0.99)(fields_number.value[indexOf(fields_number.key, 'content.duration')]) as p99_data +FROM logs.logs +WHERE timestamp >= FROM_UNIXTIME(%d) AND timestamp <= FROM_UNIXTIME(%d) %s +GROUP BY interval_data +ORDER BY interval_data +WITH FILL FROM toStartOfInterval(FROM_UNIXTIME(%d), INTERVAL %d second) TO toStartOfInterval(FROM_UNIXTIME(%d), INTERVAL %d second) STEP %d +SETTINGS skip_unavailable_shards = 1`, interval, timeStart, timeEnd, filters, timeStart, interval, timeEnd, interval, interval)) + if err != nil { + return nil, err + } + + return rows, nil +} + // New returns a new Elasticsearch instance for the given configuration. -func New(config Config, prometheusInstances []*prometheusInstance.Instance) (*Instance, error) { +func New(config Config, prometheusInstances []*prometheusInstance.Instance, clickhouseInstances []*clickhouseInstance.Instance) (*Instance, error) { var prometheusInstance *prometheusInstance.Instance + var clickhouseInstance *clickhouseInstance.Instance if config.Prometheus.Enabled { for _, instance := range prometheusInstances { @@ -230,8 +417,21 @@ func New(config Config, prometheusInstances []*prometheusInstance.Instance) (*In } } + if config.Clickhouse.Enabled { + for _, instance := range clickhouseInstances { + if instance.Name == config.Clickhouse.Name { + clickhouseInstance = instance + } + } + + if clickhouseInstance == nil { + return nil, fmt.Errorf("Clickhouse instance \"%s\" was not found", config.Clickhouse.Name) + } + } + return &Instance{ Name: config.Name, prometheus: prometheusInstance, + clickhouse: clickhouseInstance, }, nil } diff --git a/plugins/istio/src/components/page/Application.tsx b/plugins/istio/src/components/page/Application.tsx index feb2b0080..de9052abd 100644 --- a/plugins/istio/src/components/page/Application.tsx +++ b/plugins/istio/src/components/page/Application.tsx @@ -1,28 +1,16 @@ -import { - Alert, - AlertActionLink, - AlertVariant, - Card, - CardBody, - CardTitle, - Drawer, - DrawerColorVariant, - DrawerContent, - DrawerContentBody, - PageSection, - PageSectionVariants, -} from '@patternfly/react-core'; +import { Drawer, DrawerColorVariant, DrawerContent, PageSection, PageSectionVariants } from '@patternfly/react-core'; import React, { useEffect, useState } from 'react'; import { useHistory, useLocation, useParams } from 'react-router-dom'; -import { IPluginTimes, Title } from '@kobsio/plugin-core'; +import { IApplicationOptions, IFilters, IPluginOptions } from '../../utils/interfaces'; +import ApplicationMetrics from './ApplicationMetrics'; +import ApplicationTap from './ApplicationTap'; import ApplicationToolbar from './ApplicationToolbar'; -import { IPluginOptions } from '../../utils/interfaces'; -import MetricsTable from '../panel/MetricsTable'; -import Topology from '../panel/Topology'; +import ApplicationTop from './ApplicationTop'; +import { Title } from '@kobsio/plugin-core'; import { getApplicationOptionsFromSearch } from '../../utils/helpers'; -interface ITeamParams { +interface IApplicationParams { namespace: string; name: string; } @@ -32,99 +20,94 @@ export interface IApplicationProps { pluginOptions: IPluginOptions; } -const Team: React.FunctionComponent = ({ name, pluginOptions }: IApplicationProps) => { - const params = useParams(); +const Application: React.FunctionComponent = ({ name, pluginOptions }: IApplicationProps) => { + const params = useParams(); const location = useLocation(); const history = useHistory(); - const [details] = useState(undefined); + const [details, setDetails] = useState(undefined); - const [times, setTimes] = useState(getApplicationOptionsFromSearch(location.search)); + const [options, setOptions] = useState(getApplicationOptionsFromSearch(location.search)); - const changeOptions = (tmpTimes: IPluginTimes): void => { + const changeOptions = (tmpOptions: IApplicationOptions): void => { history.push({ pathname: location.pathname, - search: `?timeEnd=${tmpTimes.timeEnd}&timeStart=${tmpTimes.timeStart}`, + search: `?timeEnd=${tmpOptions.times.timeEnd}&timeStart=${tmpOptions.times.timeStart}&view=${ + tmpOptions.view + }&filterName=${encodeURIComponent(tmpOptions.filters.name)}&filterMethod=${encodeURIComponent( + tmpOptions.filters.method, + )}&filterPath=${encodeURIComponent(tmpOptions.filters.path)}`, + }); + }; + + const setFilters = (filters: IFilters): void => { + history.push({ + pathname: location.pathname, + search: `?timeEnd=${options.times.timeEnd}&timeStart=${options.times.timeStart}&view=${ + options.view + }&filterName=${encodeURIComponent(filters.name)}&filterMethod=${encodeURIComponent( + filters.method, + )}&filterPath=${encodeURIComponent(filters.path)}`, }); }; useEffect(() => { - setTimes(getApplicationOptionsFromSearch(location.search)); + setOptions(getApplicationOptionsFromSearch(location.search)); }, [location.search]); return ( - <ApplicationToolbar name={name} times={times} setOptions={changeOptions} /> + <ApplicationToolbar + view={options.view} + times={options.times} + filters={options.filters} + setOptions={changeOptions} + /> </PageSection> <Drawer isExpanded={details !== undefined}> <DrawerContent panelContent={details} colorVariant={DrawerColorVariant.light200}> - {!pluginOptions.prometheus ? ( - <DrawerContentBody> - <PageSection variant={PageSectionVariants.default}> - <Alert - variant={AlertVariant.warning} - isInline={true} - title="Prometheus plugin is not enabled" - actionLinks={ - <React.Fragment> - <AlertActionLink onClick={(): void => history.push('/')}>Home</AlertActionLink> - </React.Fragment> - } - > - <p>You have to enable the Prometheus integration in the Istio plugin configuration.</p> - </Alert> - </PageSection> - </DrawerContentBody> - ) : ( - <DrawerContentBody> - <PageSection variant={PageSectionVariants.default}> - <div style={{ height: '500px' }}> - <Topology name={name} namespace={params.namespace} application={params.name} times={times} /> - </div> - </PageSection> - - <PageSection variant={PageSectionVariants.default}> - <Card> - <CardTitle>Versions</CardTitle> - <CardBody> - <MetricsTable - name={name} - namespaces={[params.namespace]} - application={params.name} - groupBy="destination_workload_namespace, destination_workload, destination_version" - label="destination_version" - reporter="destination" - times={times} - additionalColumns={[{ label: 'destination_version', title: 'Version' }]} - /> - </CardBody> - </Card> - </PageSection> - <PageSection variant={PageSectionVariants.default}> - <Card isCompact={true}> - <CardTitle>Pods</CardTitle> - <CardBody> - <MetricsTable - name={name} - namespaces={[params.namespace]} - application={params.name} - groupBy="destination_workload_namespace, destination_workload, pod" - label="pod" - reporter="destination" - additionalColumns={[{ label: 'pod', title: 'Pod' }]} - times={times} - /> - </CardBody> - </Card> - </PageSection> - </DrawerContentBody> - )} + {options.view === 'metrics' ? ( + <ApplicationMetrics + name={name} + namespace={params.namespace} + application={params.name} + times={options.times} + filters={options.filters} + view={options.view} + pluginOptions={pluginOptions} + setDetails={setDetails} + /> + ) : options.view === 'top' ? ( + <ApplicationTop + name={name} + namespace={params.namespace} + application={params.name} + times={options.times} + filters={options.filters} + view={options.view} + pluginOptions={pluginOptions} + setFilters={setFilters} + setDetails={setDetails} + /> + ) : options.view === 'tap' ? ( + <ApplicationTap + name={name} + namespace={params.namespace} + application={params.name} + times={options.times} + filters={options.filters} + view={options.view} + pluginOptions={pluginOptions} + setFilters={setFilters} + setDetails={setDetails} + /> + ) : null} </DrawerContent> </Drawer> </React.Fragment> ); }; -export default Team; +export default Application; diff --git a/plugins/istio/src/components/page/ApplicationActions.tsx b/plugins/istio/src/components/page/ApplicationActions.tsx new file mode 100644 index 000000000..a8ee288cb --- /dev/null +++ b/plugins/istio/src/components/page/ApplicationActions.tsx @@ -0,0 +1,148 @@ +import { + Badge, + Button, + ButtonVariant, + CardActions, + Dropdown, + DropdownItem, + Form, + FormGroup, + KebabToggle, + Modal, + ModalVariant, + Switch, + TextInput, +} from '@patternfly/react-core'; +import { FilterIcon, TimesIcon } from '@patternfly/react-icons'; +import React, { useEffect, useState } from 'react'; + +import { IFilters } from '../../utils/interfaces'; + +export interface IApplicationActionsProps { + liveUpdate: boolean; + filters: IFilters; + setLiveUpdate: (value: boolean) => void; + setFilters: (value: IFilters) => void; +} + +const ApplicationActions: React.FunctionComponent<IApplicationActionsProps> = ({ + liveUpdate, + filters, + setLiveUpdate, + setFilters, +}: IApplicationActionsProps) => { + const [showDropdown, setShowDropdown] = useState<boolean>(false); + const [showModal, setShowModal] = useState<boolean>(false); + const [internalFilters, setInternalFilters] = useState<IFilters>(filters); + + const clearFilter = (): void => { + setFilters({ method: '', name: '', path: '' }); + setShowDropdown(false); + }; + + useEffect(() => { + setInternalFilters(filters); + }, [filters]); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const filtersCount = Object.keys(filters).filter((key) => (filters as any)[key] !== '').length; + + return ( + <CardActions> + <Dropdown + toggle={<KebabToggle onToggle={(): void => setShowDropdown(!showDropdown)} />} + isOpen={showDropdown} + isPlain={true} + position="right" + dropdownItems={[ + <DropdownItem + key={0} + component={ + <Switch + id="live" + label="Live Update is on" + labelOff="Live Update is off" + isChecked={liveUpdate} + onChange={(): void => setLiveUpdate(!liveUpdate)} + /> + } + />, + <DropdownItem + key={1} + component="button" + icon={<FilterIcon />} + onClick={(): void => { + setShowDropdown(false); + setShowModal(true); + }} + > + Set Filters {filtersCount > 0 ? <Badge className="pf-u-ml-md">{filtersCount}</Badge> : null} + </DropdownItem>, + <DropdownItem key={2} component="button" icon={<TimesIcon />} onClick={(): void => clearFilter()}> + Clear Filters + </DropdownItem>, + ]} + /> + + <Modal + variant={ModalVariant.small} + title="Filter" + isOpen={showModal} + onClose={(): void => setShowModal(false)} + actions={[ + <Button + key="filter" + variant={ButtonVariant.primary} + onClick={(): void => { + setFilters({ ...internalFilters }); + setShowModal(false); + }} + > + Filter + </Button>, + <Button key="cancel" variant={ButtonVariant.link} onClick={(): void => setShowModal(false)}> + Cancel + </Button>, + ]} + > + <Form isHorizontal={true}> + <FormGroup label="Name" fieldId="form-tab-name"> + <TextInput + value={internalFilters.name} + isRequired + type="text" + id="form-tab-name" + aria-describedby="form-tab-name" + name="form-tab-name" + onChange={(value): void => setInternalFilters({ ...internalFilters, name: value })} + /> + </FormGroup> + <FormGroup label="Method" fieldId="form-tab-method"> + <TextInput + value={internalFilters.method} + isRequired + type="text" + id="form-tab-method" + aria-describedby="form-tab-method" + name="form-tab-method" + onChange={(value): void => setInternalFilters({ ...internalFilters, method: value })} + /> + </FormGroup> + <FormGroup label="Path" fieldId="form-tab-path"> + <TextInput + value={internalFilters.path} + isRequired + type="text" + id="form-tab-path" + aria-describedby="form-tab-path" + name="form-tab-path" + onChange={(value): void => setInternalFilters({ ...internalFilters, path: value })} + /> + </FormGroup> + </Form> + </Modal> + </CardActions> + ); +}; + +export default ApplicationActions; diff --git a/plugins/istio/src/components/page/ApplicationMetrics.tsx b/plugins/istio/src/components/page/ApplicationMetrics.tsx new file mode 100644 index 000000000..73e135da6 --- /dev/null +++ b/plugins/istio/src/components/page/ApplicationMetrics.tsx @@ -0,0 +1,105 @@ +import { + Alert, + AlertActionLink, + AlertVariant, + Card, + CardBody, + CardTitle, + DrawerContentBody, + PageSection, + PageSectionVariants, +} from '@patternfly/react-core'; +import React from 'react'; +import { useHistory } from 'react-router'; + +import { IApplicationOptions, IPluginOptions } from '../../utils/interfaces'; +import MetricsTable from '../panel/MetricsTable'; +import Topology from '../panel/Topology'; + +export interface IApplicationMetricsProps extends IApplicationOptions { + name: string; + namespace: string; + application: string; + pluginOptions: IPluginOptions; + setDetails?: (details: React.ReactNode) => void; +} + +const ApplicationMetrics: React.FunctionComponent<IApplicationMetricsProps> = ({ + name, + namespace, + application, + times, + pluginOptions, + setDetails, +}: IApplicationMetricsProps) => { + const history = useHistory(); + + if (!pluginOptions.prometheus) { + return ( + <DrawerContentBody> + <PageSection variant={PageSectionVariants.default}> + <Alert + variant={AlertVariant.warning} + title="Prometheus plugin is not enabled" + actionLinks={ + <React.Fragment> + <AlertActionLink onClick={(): void => history.push('/')}>Home</AlertActionLink> + </React.Fragment> + } + > + <p>You have to enable the Prometheus integration in the Istio plugin configuration.</p> + </Alert> + </PageSection> + </DrawerContentBody> + ); + } + + return ( + <DrawerContentBody> + <PageSection variant={PageSectionVariants.default}> + <div style={{ height: '500px' }}> + <Topology name={name} namespace={namespace} application={application} times={times} setDetails={setDetails} /> + </div> + </PageSection> + + <PageSection variant={PageSectionVariants.default}> + <Card> + <CardTitle>Versions</CardTitle> + <CardBody> + <MetricsTable + name={name} + namespaces={[namespace]} + application={application} + groupBy="destination_workload_namespace, destination_workload, destination_version" + label="destination_version" + reporter="destination" + times={times} + additionalColumns={[{ label: 'destination_version', title: 'Version' }]} + setDetails={setDetails} + /> + </CardBody> + </Card> + </PageSection> + <PageSection variant={PageSectionVariants.default}> + <Card isCompact={true}> + <CardTitle>Pods</CardTitle> + <CardBody> + <MetricsTable + name={name} + namespaces={[namespace]} + application={application} + groupBy="destination_workload_namespace, destination_workload, pod" + label="pod" + reporter="destination" + additionalColumns={[{ label: 'pod', title: 'Pod' }]} + times={times} + setDetails={setDetails} + /> + </CardBody> + </Card> + </PageSection> + </DrawerContentBody> + ); +}; + +export default ApplicationMetrics; diff --git a/plugins/istio/src/components/page/ApplicationTap.tsx b/plugins/istio/src/components/page/ApplicationTap.tsx new file mode 100644 index 000000000..3509aadff --- /dev/null +++ b/plugins/istio/src/components/page/ApplicationTap.tsx @@ -0,0 +1,94 @@ +import { + Alert, + AlertActionLink, + AlertVariant, + Card, + CardBody, + CardHeader, + CardHeaderMain, + DrawerContentBody, + PageSection, + PageSectionVariants, +} from '@patternfly/react-core'; +import React, { useState } from 'react'; +import { useHistory } from 'react-router'; + +import { IApplicationOptions, IFilters, IPluginOptions } from '../../utils/interfaces'; +import ApplicationActions from './ApplicationActions'; +import Tap from '../panel/Tap'; + +export interface IApplicationTapProps extends IApplicationOptions { + name: string; + namespace: string; + application: string; + pluginOptions: IPluginOptions; + setFilters: (filters: IFilters) => void; + setDetails?: (details: React.ReactNode) => void; +} + +const ApplicationTap: React.FunctionComponent<IApplicationTapProps> = ({ + name, + namespace, + application, + times, + filters, + pluginOptions, + setFilters, + setDetails, +}: IApplicationTapProps) => { + const history = useHistory(); + const [liveUpdate, setLiveUpdate] = useState<boolean>(false); + + if (!pluginOptions.clickhouse) { + return ( + <DrawerContentBody> + <PageSection variant={PageSectionVariants.default}> + <Alert + variant={AlertVariant.warning} + title="Clickhouse plugin is not enabled" + actionLinks={ + <React.Fragment> + <AlertActionLink onClick={(): void => history.push('/')}>Home</AlertActionLink> + </React.Fragment> + } + > + <p>You have to enable the Clickhouse integration in the Istio plugin configuration.</p> + </Alert> + </PageSection> + </DrawerContentBody> + ); + } + + return ( + <DrawerContentBody> + <PageSection variant={PageSectionVariants.default}> + <Card isCompact={true}> + <CardHeader> + <CardHeaderMain> + <span className="pf-u-font-weight-bold">Tap</span> + </CardHeaderMain> + <ApplicationActions + liveUpdate={liveUpdate} + filters={filters} + setLiveUpdate={setLiveUpdate} + setFilters={setFilters} + /> + </CardHeader> + <CardBody> + <Tap + name={name} + namespace={namespace} + application={application} + times={times} + liveUpdate={liveUpdate} + filters={filters} + setDetails={setDetails} + /> + </CardBody> + </Card> + </PageSection> + </DrawerContentBody> + ); +}; + +export default ApplicationTap; diff --git a/plugins/istio/src/components/page/ApplicationToolbar.tsx b/plugins/istio/src/components/page/ApplicationToolbar.tsx index cc0722ee6..2295dfceb 100644 --- a/plugins/istio/src/components/page/ApplicationToolbar.tsx +++ b/plugins/istio/src/components/page/ApplicationToolbar.tsx @@ -1,18 +1,26 @@ -import { Toolbar, ToolbarContent, ToolbarGroup, ToolbarItem, ToolbarToggleGroup } from '@patternfly/react-core'; +import { + ToggleGroup, + ToggleGroupItem, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, + ToolbarToggleGroup, +} from '@patternfly/react-core'; import { FilterIcon } from '@patternfly/react-icons'; import React from 'react'; -import { IOptionsAdditionalFields, IPluginTimes, Options } from '@kobsio/plugin-core'; +import { IOptionsAdditionalFields, Options } from '@kobsio/plugin-core'; +import { IApplicationOptions } from '../../utils/interfaces'; -interface IApplicationToolbarProps { - name: string; - times: IPluginTimes; - setOptions: (data: IPluginTimes) => void; +interface IApplicationToolbarProps extends IApplicationOptions { + setOptions: (data: IApplicationOptions) => void; } const ApplicationToolbar: React.FunctionComponent<IApplicationToolbarProps> = ({ - name, + view, times, + filters, setOptions, }: IApplicationToolbarProps) => { const changeOptions = ( @@ -21,7 +29,11 @@ const ApplicationToolbar: React.FunctionComponent<IApplicationToolbarProps> = ({ timeEnd: number, timeStart: number, ): void => { - setOptions({ timeEnd: timeEnd, timeStart: timeStart }); + setOptions({ filters: filters, times: { timeEnd: timeEnd, timeStart: timeStart }, view: view }); + }; + + const setView = (v: string): void => { + setOptions({ filters: filters, times: times, view: v }); }; return ( @@ -29,7 +41,17 @@ const ApplicationToolbar: React.FunctionComponent<IApplicationToolbarProps> = ({ <ToolbarContent style={{ padding: '0px' }}> <ToolbarToggleGroup style={{ width: '100%' }} toggleIcon={<FilterIcon />} breakpoint="lg"> <ToolbarGroup style={{ width: '100%' }}> - <ToolbarItem style={{ width: '100%' }}></ToolbarItem> + <ToolbarItem style={{ width: '100%' }}> + <ToggleGroup aria-label="View"> + <ToggleGroupItem + text="Metrics" + isSelected={view === 'metrics'} + onChange={(): void => setView('metrics')} + /> + <ToggleGroupItem text="Top" isSelected={view === 'top'} onChange={(): void => setView('top')} /> + <ToggleGroupItem text="Tap" isSelected={view === 'tap'} onChange={(): void => setView('tap')} /> + </ToggleGroup> + </ToolbarItem> <ToolbarItem> <Options timeEnd={times.timeEnd} timeStart={times.timeStart} setOptions={changeOptions} /> </ToolbarItem> diff --git a/plugins/istio/src/components/page/ApplicationTop.tsx b/plugins/istio/src/components/page/ApplicationTop.tsx new file mode 100644 index 000000000..b9c69d233 --- /dev/null +++ b/plugins/istio/src/components/page/ApplicationTop.tsx @@ -0,0 +1,94 @@ +import { + Alert, + AlertActionLink, + AlertVariant, + Card, + CardBody, + CardHeader, + CardHeaderMain, + DrawerContentBody, + PageSection, + PageSectionVariants, +} from '@patternfly/react-core'; +import React, { useState } from 'react'; +import { useHistory } from 'react-router'; + +import { IApplicationOptions, IFilters, IPluginOptions } from '../../utils/interfaces'; +import ApplicationActions from './ApplicationActions'; +import Top from '../panel/Top'; + +export interface IApplicationTopProps extends IApplicationOptions { + name: string; + namespace: string; + application: string; + pluginOptions: IPluginOptions; + setFilters: (filters: IFilters) => void; + setDetails?: (details: React.ReactNode) => void; +} + +const ApplicationTop: React.FunctionComponent<IApplicationTopProps> = ({ + name, + namespace, + application, + times, + filters, + pluginOptions, + setFilters, + setDetails, +}: IApplicationTopProps) => { + const history = useHistory(); + const [liveUpdate, setLiveUpdate] = useState<boolean>(false); + + if (!pluginOptions.clickhouse) { + return ( + <DrawerContentBody> + <PageSection variant={PageSectionVariants.default}> + <Alert + variant={AlertVariant.warning} + title="Clickhouse plugin is not enabled" + actionLinks={ + <React.Fragment> + <AlertActionLink onClick={(): void => history.push('/')}>Home</AlertActionLink> + </React.Fragment> + } + > + <p>You have to enable the Clickhouse integration in the Istio plugin configuration.</p> + </Alert> + </PageSection> + </DrawerContentBody> + ); + } + + return ( + <DrawerContentBody> + <PageSection variant={PageSectionVariants.default}> + <Card isCompact={true}> + <CardHeader> + <CardHeaderMain> + <span className="pf-u-font-weight-bold">Tap</span> + </CardHeaderMain> + <ApplicationActions + liveUpdate={liveUpdate} + filters={filters} + setLiveUpdate={setLiveUpdate} + setFilters={setFilters} + /> + </CardHeader> + <CardBody> + <Top + name={name} + namespace={namespace} + application={application} + times={times} + liveUpdate={liveUpdate} + filters={filters} + setDetails={setDetails} + /> + </CardBody> + </Card> + </PageSection> + </DrawerContentBody> + ); +}; + +export default ApplicationTop; diff --git a/plugins/istio/src/components/page/Applications.tsx b/plugins/istio/src/components/page/Applications.tsx index fe37883d5..edee96edf 100644 --- a/plugins/istio/src/components/page/Applications.tsx +++ b/plugins/istio/src/components/page/Applications.tsx @@ -14,7 +14,7 @@ import { import React, { useEffect, useState } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; -import { IOptions, IPluginOptions } from '../../utils/interfaces'; +import { IApplicationsOptions, IPluginOptions } from '../../utils/interfaces'; import ApplicationsToolbar from './ApplicationsToolbar'; import { IRowValues } from '@kobsio/plugin-prometheus'; import MetricsTable from '../panel/MetricsTable'; @@ -35,9 +35,9 @@ const Applications: React.FunctionComponent<IApplicationsProps> = ({ }: IApplicationsProps) => { const location = useLocation(); const history = useHistory(); - const [options, setOptions] = useState<IOptions>(getApplicationsOptionsFromSearch(location.search)); + const [options, setOptions] = useState<IApplicationsOptions>(getApplicationsOptionsFromSearch(location.search)); - const changeOptions = (opts: IOptions): void => { + const changeOptions = (opts: IApplicationsOptions): void => { const namespaces = opts.namespaces ? opts.namespaces.map((namespace) => `&namespace=${namespace}`) : []; history.push({ @@ -94,7 +94,7 @@ const Applications: React.FunctionComponent<IApplicationsProps> = ({ label="destination_workload" reporter="destination" times={options.times} - showDetails={(row: IRowValues): void => + goTo={(row: IRowValues): void => history.push({ pathname: `/${name}/${row['destination_workload_namespace']}/${row['destination_workload']}`, }) diff --git a/plugins/istio/src/components/page/ApplicationsToolbar.tsx b/plugins/istio/src/components/page/ApplicationsToolbar.tsx index 81d477cca..d05a2498d 100644 --- a/plugins/istio/src/components/page/ApplicationsToolbar.tsx +++ b/plugins/istio/src/components/page/ApplicationsToolbar.tsx @@ -16,11 +16,11 @@ import React, { useState } from 'react'; import { useQuery } from 'react-query'; import { IOptionsAdditionalFields, IPluginTimes, Options } from '@kobsio/plugin-core'; -import { IOptions } from '../../utils/interfaces'; +import { IApplicationsOptions } from '../../utils/interfaces'; -interface IPageToolbarProps extends IOptions { +interface IPageToolbarProps extends IApplicationsOptions { name: string; - setOptions: (data: IOptions) => void; + setOptions: (data: IApplicationsOptions) => void; } const PageToolbar: React.FunctionComponent<IPageToolbarProps> = ({ diff --git a/plugins/istio/src/components/page/Page.tsx b/plugins/istio/src/components/page/Page.tsx index 926e4b1c8..8c4220be3 100644 --- a/plugins/istio/src/components/page/Page.tsx +++ b/plugins/istio/src/components/page/Page.tsx @@ -17,6 +17,7 @@ const Page: React.FunctionComponent<IPluginPageProps> = ({ } const pluginOptions: IPluginOptions = { + clickhouse: options['clickhouse'], prometheus: options['prometheus'], }; diff --git a/plugins/istio/src/components/panel/MetricsTable.tsx b/plugins/istio/src/components/panel/MetricsTable.tsx index 46fe18c9b..cd2695a97 100644 --- a/plugins/istio/src/components/panel/MetricsTable.tsx +++ b/plugins/istio/src/components/panel/MetricsTable.tsx @@ -4,6 +4,7 @@ import { TableComposable, TableVariant, Tbody, Td, Th, Thead, Tr } from '@patter import React from 'react'; import { IRowValues, IRows } from '@kobsio/plugin-prometheus'; +import DetailsMetrics from './details/DetailsMetrics'; import { IPluginTimes } from '@kobsio/plugin-core'; import { formatNumber } from '../../utils/helpers'; @@ -21,7 +22,8 @@ export interface IMetricsTableProps { reporter: string; times: IPluginTimes; additionalColumns?: IAdditionalColumns[]; - showDetails?: (row: IRowValues) => void; + setDetails?: (details: React.ReactNode) => void; + goTo?: (row: IRowValues) => void; } const MetricsTable: React.FunctionComponent<IMetricsTableProps> = ({ @@ -33,7 +35,8 @@ const MetricsTable: React.FunctionComponent<IMetricsTableProps> = ({ reporter, times, additionalColumns, - showDetails, + setDetails, + goTo, }: IMetricsTableProps) => { const { isError, isLoading, error, data, refetch } = useQuery<IRows, Error>( ['istio/metrics', name, namespaces, application, label, groupBy, reporter, times], @@ -81,7 +84,7 @@ const MetricsTable: React.FunctionComponent<IMetricsTableProps> = ({ <Alert variant={AlertVariant.danger} isInline={true} - title="Could not get applications" + title="Could not get metrics" actionLinks={ <React.Fragment> <AlertActionLink onClick={(): Promise<QueryObserverResult<IRows, Error>> => refetch()}> @@ -117,8 +120,24 @@ const MetricsTable: React.FunctionComponent<IMetricsTableProps> = ({ {Object.keys(data).map((key) => ( <Tr key={key} - isHoverable={showDetails ? true : false} - onClick={showDetails ? (): void => showDetails(data[key]) : undefined} + isHoverable={goTo || setDetails ? true : false} + onClick={ + goTo + ? (): void => goTo(data[key]) + : setDetails + ? (): void => + setDetails( + <DetailsMetrics + name={name} + namespace={data[key]['destination_workload_namespace']} + application={data[key]['destination_workload']} + row={data[key]} + times={times} + close={(): void => setDetails(undefined)} + />, + ) + : undefined + } > <Td dataLabel="Application">{data[key]['destination_workload']}</Td> <Td dataLabel="Namespace">{data[key]['destination_workload_namespace']}</Td> diff --git a/plugins/istio/src/components/panel/Tap.tsx b/plugins/istio/src/components/panel/Tap.tsx new file mode 100644 index 000000000..f279065c2 --- /dev/null +++ b/plugins/istio/src/components/panel/Tap.tsx @@ -0,0 +1,153 @@ +import { Alert, AlertActionLink, AlertVariant, Spinner } from '@patternfly/react-core'; +import { QueryObserverResult, useQuery } from 'react-query'; +import { TableComposable, TableVariant, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; +import React from 'react'; + +import { IFilters, ILogLine } from '../../utils/interfaces'; +import DetailsTap from './details/DetailsTap'; +import { IPluginTimes } from '@kobsio/plugin-core'; +import { getDirection } from '../../utils/helpers'; + +export interface IAdditionalColumns { + title: string; + label: string; +} + +export interface ITapProps { + name: string; + namespace: string; + application: string; + times: IPluginTimes; + liveUpdate: boolean; + filters: IFilters; + setDetails?: (details: React.ReactNode) => void; +} + +const Tap: React.FunctionComponent<ITapProps> = ({ + name, + namespace, + application, + times, + liveUpdate, + filters, + setDetails, +}: ITapProps) => { + const { isError, isLoading, error, data, refetch } = useQuery<ILogLine[], Error>( + ['istio/tap', name, namespace, application, times, liveUpdate, filters], + async () => { + try { + // When live update is enabled, we do not use the selected start and end time, instead we are using the same + // time range but with the end time set to now. + const timeEnd = liveUpdate ? Math.floor(Math.floor(Date.now() / 1000)) : times.timeEnd; + const timeStart = liveUpdate + ? Math.floor(Math.floor(Date.now() / 1000)) - (times.timeEnd - times.timeStart) + : times.timeStart; + + const response = await fetch( + `/api/plugins/istio/tap/${name}?timeStart=${timeStart}&timeEnd=${timeEnd}&application=${application}&namespace=${namespace}&filterName=${encodeURIComponent( + filters.name, + )}&filterMethod=${encodeURIComponent(filters.method)}&filterPath=${encodeURIComponent(filters.path)}`, + { + method: 'get', + }, + ); + const json = await response.json(); + + if (response.status >= 200 && response.status < 300) { + return json; + } else { + if (json.error) { + throw new Error(json.error); + } else { + throw new Error('An unknown error occured'); + } + } + } catch (err) { + throw err; + } + }, + { + refetchInterval: liveUpdate ? 5000 : false, + }, + ); + + if (isLoading) { + return ( + <div className="pf-u-text-align-center"> + <Spinner /> + </div> + ); + } + + if (isError) { + return ( + <Alert + variant={AlertVariant.danger} + isInline={true} + title="Could not get data" + actionLinks={ + <React.Fragment> + <AlertActionLink onClick={(): Promise<QueryObserverResult<ILogLine[], Error>> => refetch()}> + Retry + </AlertActionLink> + </React.Fragment> + } + > + <p>{error?.message}</p> + </Alert> + ); + } + + if (!data) { + return null; + } + + return ( + <TableComposable aria-label="Tap" variant={TableVariant.compact} borders={false}> + <Thead> + <Tr> + <Th>Direction</Th> + <Th>Name</Th> + <Th>Method</Th> + <Th>Path</Th> + <Th>Latency</Th> + <Th>HTTP Status</Th> + <Th>gRPC Status</Th> + </Tr> + </Thead> + <Tbody> + {data.map((line, index) => ( + <Tr + key={index} + isHoverable={setDetails ? true : false} + onClick={ + setDetails + ? (): void => setDetails(<DetailsTap line={line} close={(): void => setDetails(undefined)} />) + : undefined + } + > + <Td dataLabel="Direction"> + {line.hasOwnProperty('content.upstream_cluster') ? getDirection(line['content.upstream_cluster']) : '-'} + </Td> + <Td dataLabel="Name">{line.hasOwnProperty('content.authority') ? line['content.authority'] : '-'}</Td> + <Td dataLabel="Method">{line.hasOwnProperty('content.method') ? line['content.method'] : '-'}</Td> + <Td className="pf-u-text-wrap pf-u-text-break-word" dataLabel="Path"> + {line.hasOwnProperty('content.path') ? line['content.path'] : '-'} + </Td> + <Td className="pf-u-text-nowrap" dataLabel="Latency"> + {line.hasOwnProperty('content.duration') ? `${line['content.duration']} ms` : '-'} + </Td> + <Td className="pf-u-text-nowrap" dataLabel="HTTP Status"> + {line.hasOwnProperty('content.response_code') ? line['content.response_code'] : '-'} + </Td> + <Td className="pf-u-text-nowrap" dataLabel="gRPC Status"> + {line.hasOwnProperty('content.grpc_status') ? line['content.grpc_status'] : '-'} + </Td> + </Tr> + ))} + </Tbody> + </TableComposable> + ); +}; + +export default Tap; diff --git a/plugins/istio/src/components/panel/Top.tsx b/plugins/istio/src/components/panel/Top.tsx new file mode 100644 index 000000000..c444ceba7 --- /dev/null +++ b/plugins/istio/src/components/panel/Top.tsx @@ -0,0 +1,240 @@ +import { Alert, AlertActionLink, AlertVariant, Button, ButtonVariant, Spinner } from '@patternfly/react-core'; +import { + IExtraColumnData, + SortByDirection, + TableComposable, + TableVariant, + Tbody, + Td, + Th, + Thead, + Tr, +} from '@patternfly/react-table'; +import { QueryObserverResult, useQuery } from 'react-query'; +import React, { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { MicroscopeIcon } from '@patternfly/react-icons'; + +import { escapeRegExp, formatNumber, getDirection } from '../../utils/helpers'; +import DetailsTop from './details/DetailsTop'; +import { IFilters } from '../../utils/interfaces'; +import { IPluginTimes } from '@kobsio/plugin-core'; + +export interface ISort { + direction: 'asc' | 'desc'; + index: number; +} + +const getSortParameters = (sort: ISort): [string, string] => { + const sortDirection = sort.direction === 'asc' ? 'ASC' : 'DESC'; + + switch (sort.index) { + case 0: + return ['count', sortDirection]; + case 1: + return ['min', sortDirection]; + case 2: + return ['max', sortDirection]; + case 3: + return ['avg', sortDirection]; + case 4: + return ['last', sortDirection]; + case 5: + return ['sr', sortDirection]; + default: + return ['count', sortDirection]; + } +}; + +export interface ITopProps { + name: string; + namespace: string; + application: string; + times: IPluginTimes; + liveUpdate: boolean; + filters: IFilters; + setDetails?: (details: React.ReactNode) => void; +} + +const Top: React.FunctionComponent<ITopProps> = ({ + name, + namespace, + application, + times, + liveUpdate, + filters, + setDetails, +}: ITopProps) => { + const [sort, setSort] = useState<ISort>({ direction: SortByDirection.desc, index: 0 }); + + const { isError, isLoading, error, data, refetch } = useQuery<string[][], Error>( + ['istio/top', name, namespace, application, times, liveUpdate, filters, sort], + async () => { + try { + // Instead of modifying the end and start time like in the tap view we are just setting the end time to now to + // run the aggregations for the top view. + const timeEnd = liveUpdate ? Math.floor(Math.floor(Date.now() / 1000)) : times.timeEnd; + const [sortBy, sortDirection] = getSortParameters(sort); + + const response = await fetch( + `/api/plugins/istio/top/${name}?timeStart=${ + times.timeStart + }&timeEnd=${timeEnd}&application=${application}&namespace=${namespace}&filterName=${encodeURIComponent( + filters.name, + )}&filterMethod=${encodeURIComponent(filters.method)}&filterPath=${encodeURIComponent( + filters.path, + )}&sortBy=${sortBy}&sortDirection=${sortDirection}`, + { + method: 'get', + }, + ); + const json = await response.json(); + + if (response.status >= 200 && response.status < 300) { + return json; + } else { + if (json.error) { + throw new Error(json.error); + } else { + throw new Error('An unknown error occured'); + } + } + } catch (err) { + throw err; + } + }, + { + refetchInterval: liveUpdate ? 5000 : false, + }, + ); + + const onTdClick = (row: string[]): void => { + if (setDetails) { + setDetails( + <DetailsTop + name={name} + namespace={namespace} + application={application} + row={row} + times={times} + close={(): void => setDetails(undefined)} + />, + ); + } + }; + + if (isLoading) { + return ( + <div className="pf-u-text-align-center"> + <Spinner /> + </div> + ); + } + + if (isError) { + return ( + <Alert + variant={AlertVariant.danger} + isInline={true} + title="Could not get data" + actionLinks={ + <React.Fragment> + <AlertActionLink onClick={(): Promise<QueryObserverResult<string[][], Error>> => refetch()}> + Retry + </AlertActionLink> + </React.Fragment> + } + > + <p>{error?.message}</p> + </Alert> + ); + } + + if (!data) { + return null; + } + + return ( + <TableComposable aria-label="Tap" variant={TableVariant.compact} borders={false}> + <Thead> + <Tr> + <Th>Direction</Th> + <Th>Name</Th> + <Th>Method</Th> + <Th>Path</Th> + {['Count', 'Best', 'Worst', 'Avg', 'Last', 'SR'].map((column, index) => ( + <Th + key={index} + sort={{ + columnIndex: index, + onSort: ( + event: React.MouseEvent, + columnIndex: number, + sortByDirection: SortByDirection, + extraData: IExtraColumnData, + ): void => { + setSort({ direction: sortByDirection, index: index }); + }, + sortBy: { direction: sort.direction, index: sort.index }, + }} + > + {column} + </Th> + ))} + <Th /> + </Tr> + </Thead> + <Tbody> + {data.map((row, index) => ( + <Tr key={index} isHoverable={setDetails ? true : false}> + <Td dataLabel="Direction" onClick={(): void => onTdClick(row)}> + {getDirection(row[0]) || '-'} + </Td> + <Td dataLabel="Name" onClick={(): void => onTdClick(row)}> + {row[1] || '-'} + </Td> + <Td dataLabel="Method" onClick={(): void => onTdClick(row)}> + {row[2] || '-'} + </Td> + <Td className="pf-u-text-wrap pf-u-text-break-word" dataLabel="Path" onClick={(): void => onTdClick(row)}> + {row[3] || '-'} + </Td> + <Td className="pf-u-text-nowrap" dataLabel="Count" onClick={(): void => onTdClick(row)}> + {formatNumber(row[4])} + </Td> + <Td className="pf-u-text-nowrap" dataLabel="Best" onClick={(): void => onTdClick(row)}> + {formatNumber(row[5], 'ms', 0)} + </Td> + <Td className="pf-u-text-nowrap" dataLabel="Worst" onClick={(): void => onTdClick(row)}> + {formatNumber(row[6], 'ms', 0)} + </Td> + <Td className="pf-u-text-nowrap" dataLabel="Avg" onClick={(): void => onTdClick(row)}> + {formatNumber(row[7], 'ms', 0)} + </Td> + <Td className="pf-u-text-nowrap" dataLabel="Last" onClick={(): void => onTdClick(row)}> + {formatNumber(row[8], 'ms', 0)} + </Td> + <Td className="pf-u-text-nowrap" dataLabel="SR" onClick={(): void => onTdClick(row)}> + {formatNumber(row[9], '%', 2)} + </Td> + <Td noPadding={true} style={{ padding: 0 }}> + <Link + to={`/${name}/${namespace}/${application}?view=tap&timeStart=${times.timeStart}&timeEnd=${ + times.timeEnd + }&filterName=${encodeURIComponent(row[1])}&filterMethod=${encodeURIComponent( + row[2], + )}&filterPath=${encodeURIComponent(escapeRegExp(row[3]))}`} + > + <Button variant={ButtonVariant.plain}> + <MicroscopeIcon /> + </Button> + </Link> + </Td> + </Tr> + ))} + </Tbody> + </TableComposable> + ); +}; + +export default Top; diff --git a/plugins/istio/src/components/panel/Topology.tsx b/plugins/istio/src/components/panel/Topology.tsx index 4dbf53305..50453127a 100644 --- a/plugins/istio/src/components/panel/Topology.tsx +++ b/plugins/istio/src/components/panel/Topology.tsx @@ -3,7 +3,6 @@ import { QueryObserverResult, useQuery } from 'react-query'; import React from 'react'; import { IPluginTimes } from '@kobsio/plugin-core'; -import { IRowValues } from '@kobsio/plugin-prometheus'; import { ITopology } from '../../utils/interfaces'; import TopologyGraph from './TopologyGraph'; @@ -17,7 +16,7 @@ export interface ITopologyProps { namespace: string; application: string; times: IPluginTimes; - showDetails?: (row: IRowValues) => void; + setDetails?: (details: React.ReactNode) => void; } const Topology: React.FunctionComponent<ITopologyProps> = ({ @@ -25,7 +24,7 @@ const Topology: React.FunctionComponent<ITopologyProps> = ({ namespace, application, times, - showDetails, + setDetails, }: ITopologyProps) => { const { isError, isLoading, error, data, refetch } = useQuery<ITopology, Error>( ['istio/topology', name, namespace, application, times], @@ -84,7 +83,17 @@ const Topology: React.FunctionComponent<ITopologyProps> = ({ return null; } - return <TopologyGraph edges={data.edges} nodes={data.nodes} showDetails={showDetails} />; + return ( + <TopologyGraph + name={name} + edges={data.edges} + nodes={data.nodes} + namespace={namespace} + application={application} + times={times} + setDetails={setDetails} + /> + ); }; export default Topology; diff --git a/plugins/istio/src/components/panel/TopologyGraph.tsx b/plugins/istio/src/components/panel/TopologyGraph.tsx index 5ba134351..290ecbc83 100644 --- a/plugins/istio/src/components/panel/TopologyGraph.tsx +++ b/plugins/istio/src/components/panel/TopologyGraph.tsx @@ -6,7 +6,8 @@ import nodeHtmlLabel from 'cytoscape-node-html-label'; import dagre from 'cytoscape-dagre'; import { IEdge, INode, INodeData } from '../../utils/interfaces'; -import { IRowValues } from '@kobsio/plugin-prometheus'; +import DetailsMetrics from './details/DetailsMetrics'; +import { IPluginTimes } from '@kobsio/plugin-core'; import { formatNumber } from '../../utils/helpers'; cytoscape.use(dagre); @@ -124,15 +125,23 @@ const nodeLabel = (node: INodeData): string => { }; interface ITopologyGraphProps { + name: string; edges: IEdge[]; nodes: INode[]; - showDetails?: (row: IRowValues) => void; + namespace: string; + application: string; + times: IPluginTimes; + setDetails?: (details: React.ReactNode) => void; } const TopologyGraph: React.FunctionComponent<ITopologyGraphProps> = ({ + name, edges, nodes, - showDetails, + namespace, + application, + times, + setDetails, }: ITopologyGraphProps) => { const [width, setWidth] = useState<number>(0); const [height, setHeight] = useState<number>(0); @@ -146,11 +155,20 @@ const TopologyGraph: React.FunctionComponent<ITopologyGraphProps> = ({ const node = event.target; const data: INodeData = node.data(); - if (data.metrics && showDetails) { - showDetails(data.metrics); + if (data.metrics && setDetails) { + setDetails( + <DetailsMetrics + name={name} + namespace={namespace} + application={application} + row={data.metrics} + times={times} + close={(): void => setDetails(undefined)} + />, + ); } }, - [showDetails], + [name, namespace, application, times, setDetails], ); const cyCallback = useCallback( diff --git a/plugins/istio/src/components/panel/details/DetailsMetrics.tsx b/plugins/istio/src/components/panel/details/DetailsMetrics.tsx new file mode 100644 index 000000000..d7e98a6db --- /dev/null +++ b/plugins/istio/src/components/panel/details/DetailsMetrics.tsx @@ -0,0 +1,348 @@ +import { + DrawerActions, + DrawerCloseButton, + DrawerHead, + DrawerPanelBody, + DrawerPanelContent, +} from '@patternfly/react-core'; +import React from 'react'; + +import { IPluginTimes, Title } from '@kobsio/plugin-core'; +import DetailsMetricsMetric from './DetailsMetricsMetric'; +import DetailsMetricsPod from './DetailsMetricsPod'; +import { IRowValues } from '@kobsio/plugin-prometheus'; + +const getTitle = (row: IRowValues): string => { + if (row.hasOwnProperty('destination_version')) { + return `Version: ${row['destination_version']}`; + } else if (row.hasOwnProperty('pod')) { + return `Pod: ${row['pod']}`; + } else if (row.hasOwnProperty('destination_service')) { + return `Destination: ${row['destination_service']}`; + } else if (row.hasOwnProperty('source_workload') && row.hasOwnProperty('source_workload_namespace')) { + return `Source: ${row['source_workload']} (${row['source_workload_namespace']})`; + } + + return 'Details'; +}; + +interface IDetailsMetricsProps { + name: string; + namespace: string; + application: string; + row: IRowValues; + times: IPluginTimes; + close: () => void; +} + +const DetailsMetrics: React.FunctionComponent<IDetailsMetricsProps> = ({ + name, + namespace, + application, + row, + times, + close, +}: IDetailsMetricsProps) => { + return ( + <DrawerPanelContent minSize="50%"> + <DrawerHead> + <Title title={getTitle(row)} subtitle={`${application} (${namespace})`} size="lg" /> + <DrawerActions style={{ padding: 0 }}> + <DrawerCloseButton onClose={close} /> + </DrawerActions> + </DrawerHead> + + <DrawerPanelBody> + {row.hasOwnProperty('destination_version') ? ( + <div> + <DetailsMetricsMetric + name={name} + title="Success Rate" + metric="sr" + unit="%" + reporter="destination" + destinationWorkload={application} + destinationWorkloadNamespace={namespace} + destinationVersion={row['destination_version']} + destinationService=".*" + sourceWorkload=".*" + sourceWorkloadNamespace=".*" + pod=".*" + times={times} + /> + <p> </p> + <DetailsMetricsMetric + name={name} + title="Requests per Second" + metric="rps" + unit="" + reporter="destination" + destinationWorkload={application} + destinationWorkloadNamespace={namespace} + destinationVersion={row['destination_version']} + destinationService=".*" + sourceWorkload=".*" + sourceWorkloadNamespace=".*" + pod=".*" + times={times} + /> + <p> </p> + <DetailsMetricsMetric + name={name} + title="Latency" + metric="latency" + unit="ms" + reporter="destination" + destinationWorkload={application} + destinationWorkloadNamespace={namespace} + destinationVersion={row['destination_version']} + destinationService=".*" + sourceWorkload=".*" + sourceWorkloadNamespace=".*" + pod=".*" + times={times} + /> + <p> </p> + </div> + ) : row.hasOwnProperty('pod') ? ( + <div> + <DetailsMetricsMetric + name={name} + title="Success Rate" + metric="sr" + unit="%" + reporter="destination" + destinationWorkload={application} + destinationWorkloadNamespace={namespace} + destinationVersion=".*" + destinationService=".*" + sourceWorkload=".*" + sourceWorkloadNamespace=".*" + pod={row['pod']} + times={times} + /> + <p> </p> + <DetailsMetricsMetric + name={name} + title="Requests per Second" + metric="rps" + unit="" + reporter="destination" + destinationWorkload={application} + destinationWorkloadNamespace={namespace} + destinationVersion=".*" + destinationService=".*" + sourceWorkload=".*" + sourceWorkloadNamespace=".*" + pod={row['pod']} + times={times} + /> + <p> </p> + <DetailsMetricsMetric + name={name} + title="Latency" + metric="latency" + unit="ms" + reporter="destination" + destinationWorkload={application} + destinationWorkloadNamespace={namespace} + destinationVersion=".*" + destinationService=".*" + sourceWorkload=".*" + sourceWorkloadNamespace=".*" + pod={row['pod']} + times={times} + /> + <p> </p> + + <DetailsMetricsPod + name={name} + title="CPU Usage" + metric="cpu" + unit="Cores" + namespace={namespace} + pod={row['pod']} + times={times} + /> + <p> </p> + <DetailsMetricsPod + name={name} + title="CPU Throttling" + metric="throttling" + unit="%" + namespace={namespace} + pod={row['pod']} + times={times} + /> + <p> </p> + <DetailsMetricsPod + name={name} + title="Memory Usage" + metric="memory" + unit="MiB" + namespace={namespace} + pod={row['pod']} + times={times} + /> + <p> </p> + </div> + ) : row.hasOwnProperty('destination_service') ? ( + <div> + <DetailsMetricsMetric + name={name} + title="Success Rate" + metric="sr" + unit="%" + reporter="source" + destinationWorkload=".*" + destinationWorkloadNamespace=".*" + destinationVersion=".*" + destinationService={row['destination_service']} + sourceWorkload={application} + sourceWorkloadNamespace={namespace} + pod=".*" + times={times} + /> + <p> </p> + <DetailsMetricsMetric + name={name} + title="Requests per Second" + metric="rps" + unit="" + reporter="source" + destinationWorkload=".*" + destinationWorkloadNamespace=".*" + destinationVersion=".*" + destinationService={row['destination_service']} + sourceWorkload={application} + sourceWorkloadNamespace={namespace} + pod=".*" + times={times} + /> + <p> </p> + <DetailsMetricsMetric + name={name} + title="Latency" + metric="latency" + unit="ms" + reporter="source" + destinationWorkload=".*" + destinationWorkloadNamespace=".*" + destinationVersion=".*" + destinationService={row['destination_service']} + sourceWorkload={application} + sourceWorkloadNamespace={namespace} + pod=".*" + times={times} + /> + <p> </p> + </div> + ) : row.hasOwnProperty('source_workload') && row.hasOwnProperty('source_workload_namespace') ? ( + <div> + <DetailsMetricsMetric + name={name} + title="Success Rate" + metric="sr" + unit="%" + reporter="destination" + destinationWorkload={application} + destinationWorkloadNamespace={namespace} + destinationVersion=".*" + destinationService=".*" + sourceWorkload={row['source_workload']} + sourceWorkloadNamespace={row['source_workload_namespace']} + pod=".*" + times={times} + /> + <p> </p> + <DetailsMetricsMetric + name={name} + title="Requests per Second" + metric="rps" + unit="" + reporter="destination" + destinationWorkload={application} + destinationWorkloadNamespace={namespace} + destinationVersion=".*" + destinationService=".*" + sourceWorkload={row['source_workload']} + sourceWorkloadNamespace={row['source_workload_namespace']} + pod=".*" + times={times} + /> + <p> </p> + <DetailsMetricsMetric + name={name} + title="Latency" + metric="latency" + unit="ms" + reporter="destination" + destinationWorkload={application} + destinationWorkloadNamespace={namespace} + destinationVersion=".*" + destinationService=".*" + sourceWorkload={row['source_workload']} + sourceWorkloadNamespace={row['source_workload_namespace']} + pod=".*" + times={times} + /> + <p> </p> + </div> + ) : ( + <div> + <DetailsMetricsMetric + name={name} + title="Success Rate" + metric="sr" + unit="%" + reporter="destination" + destinationWorkload={application} + destinationWorkloadNamespace={namespace} + destinationVersion=".*" + destinationService=".*" + sourceWorkload=".*" + sourceWorkloadNamespace=".*" + pod=".*" + times={times} + /> + <p> </p> + <DetailsMetricsMetric + name={name} + title="Requests per Second" + metric="rps" + unit="" + reporter="destination" + destinationWorkload={application} + destinationWorkloadNamespace={namespace} + destinationVersion=".*" + destinationService=".*" + sourceWorkload=".*" + sourceWorkloadNamespace=".*" + pod=".*" + times={times} + /> + <p> </p> + <DetailsMetricsMetric + name={name} + title="Latency" + metric="latency" + unit="ms" + reporter="destination" + destinationWorkload={application} + destinationWorkloadNamespace={namespace} + destinationVersion=".*" + destinationService=".*" + sourceWorkload=".*" + sourceWorkloadNamespace=".*" + pod=".*" + times={times} + /> + <p> </p> + </div> + )} + </DrawerPanelBody> + </DrawerPanelContent> + ); +}; + +export default DetailsMetrics; diff --git a/plugins/istio/src/components/panel/details/DetailsMetricsMetric.tsx b/plugins/istio/src/components/panel/details/DetailsMetricsMetric.tsx new file mode 100644 index 000000000..e377291d5 --- /dev/null +++ b/plugins/istio/src/components/panel/details/DetailsMetricsMetric.tsx @@ -0,0 +1,124 @@ +import { Alert, AlertActionLink, AlertVariant, Card, CardBody, CardTitle, Spinner } from '@patternfly/react-core'; +import { QueryObserverResult, useQuery } from 'react-query'; +import React from 'react'; + +import { Chart, ISeries, convertMetrics } from '@kobsio/plugin-prometheus'; +import { IPluginTimes } from '@kobsio/plugin-core'; + +interface IDetailsMetricsMetricProps { + name: string; + title: string; + metric: 'sr' | 'rps' | 'latency'; + unit: string; + reporter: 'destination' | 'source'; + destinationWorkload: string; + destinationWorkloadNamespace: string; + destinationVersion: string; + destinationService: string; + sourceWorkload: string; + sourceWorkloadNamespace: string; + pod: string; + times: IPluginTimes; +} + +const DetailsMetricsMetric: React.FunctionComponent<IDetailsMetricsMetricProps> = ({ + name, + title, + metric, + unit, + reporter, + destinationWorkload, + destinationWorkloadNamespace, + destinationVersion, + destinationService, + sourceWorkload, + sourceWorkloadNamespace, + pod, + times, +}: IDetailsMetricsMetricProps) => { + const { isError, isLoading, error, data, refetch } = useQuery<ISeries, Error>( + [ + 'istio/metricsdetails', + name, + metric, + reporter, + destinationWorkload, + destinationWorkloadNamespace, + destinationVersion, + destinationService, + sourceWorkload, + sourceWorkloadNamespace, + pod, + times, + ], + async () => { + try { + const response = await fetch( + `/api/plugins/istio/metricsdetails/${name}?timeStart=${times.timeStart}&timeEnd=${times.timeEnd}&metric=${metric}&reporter=${reporter}&destinationWorkload=${destinationWorkload}&destinationWorkloadNamespace=${destinationWorkloadNamespace}&destinationVersion=${destinationVersion}&destinationService=${destinationService}&sourceWorkload=${sourceWorkload}&sourceWorkloadNamespace=${sourceWorkloadNamespace}&pod=${pod}`, + { + method: 'get', + }, + ); + const json = await response.json(); + + if (response.status >= 200 && response.status < 300) { + if (json && json.metrics) { + return convertMetrics(json.metrics, json.startTime, json.endTime, json.min, json.max); + } else { + return { endTime: times.timeEnd, labels: {}, max: 0, min: 0, series: [], startTime: times.timeStart }; + } + } else { + if (json.error) { + throw new Error(json.error); + } else { + throw new Error('An unknown error occured'); + } + } + } catch (err) { + throw err; + } + }, + ); + + return ( + <Card isCompact={true}> + <CardTitle>{title}</CardTitle> + <CardBody> + {isLoading ? ( + <div className="pf-u-text-align-center"> + <Spinner /> + </div> + ) : isError ? ( + <Alert + variant={AlertVariant.danger} + isInline={true} + title="Could not get metrics" + actionLinks={ + <React.Fragment> + <AlertActionLink onClick={(): Promise<QueryObserverResult<ISeries, Error>> => refetch()}> + Retry + </AlertActionLink> + </React.Fragment> + } + > + <p>{error?.message}</p> + </Alert> + ) : data ? ( + <div style={{ height: '300px' }}> + <Chart + startTime={data.startTime} + endTime={data.endTime} + min={data.min} + max={data.max} + options={{ stacked: false, type: 'line', unit: unit }} + labels={data.labels} + series={data.series} + /> + </div> + ) : null} + </CardBody> + </Card> + ); +}; + +export default DetailsMetricsMetric; diff --git a/plugins/istio/src/components/panel/details/DetailsMetricsPod.tsx b/plugins/istio/src/components/panel/details/DetailsMetricsPod.tsx new file mode 100644 index 000000000..cabb44149 --- /dev/null +++ b/plugins/istio/src/components/panel/details/DetailsMetricsPod.tsx @@ -0,0 +1,99 @@ +import { Alert, AlertActionLink, AlertVariant, Card, CardBody, CardTitle, Spinner } from '@patternfly/react-core'; +import { QueryObserverResult, useQuery } from 'react-query'; +import React from 'react'; + +import { Chart, ISeries, convertMetrics } from '@kobsio/plugin-prometheus'; +import { IPluginTimes } from '@kobsio/plugin-core'; + +interface IDetailsMetricsPodProps { + name: string; + title: string; + metric: 'cpu' | 'throttling' | 'memory'; + unit: string; + namespace: string; + pod: string; + times: IPluginTimes; +} + +const DetailsMetricsPod: React.FunctionComponent<IDetailsMetricsPodProps> = ({ + name, + title, + metric, + unit, + namespace, + pod, + times, +}: IDetailsMetricsPodProps) => { + const { isError, isLoading, error, data, refetch } = useQuery<ISeries, Error>( + ['istio/metricspod', name, metric, namespace, pod, times], + async () => { + try { + const response = await fetch( + `/api/plugins/istio/metricspod/${name}?timeStart=${times.timeStart}&timeEnd=${times.timeEnd}&metric=${metric}&namespace=${namespace}&pod=${pod}`, + { + method: 'get', + }, + ); + const json = await response.json(); + + if (response.status >= 200 && response.status < 300) { + if (json && json.metrics) { + return convertMetrics(json.metrics, json.startTime, json.endTime, json.min, json.max); + } else { + return { endTime: times.timeEnd, labels: {}, max: 0, min: 0, series: [], startTime: times.timeStart }; + } + } else { + if (json.error) { + throw new Error(json.error); + } else { + throw new Error('An unknown error occured'); + } + } + } catch (err) { + throw err; + } + }, + ); + + return ( + <Card isCompact={true}> + <CardTitle>{title}</CardTitle> + <CardBody> + {isLoading ? ( + <div className="pf-u-text-align-center"> + <Spinner /> + </div> + ) : isError ? ( + <Alert + variant={AlertVariant.danger} + isInline={true} + title="Could not get metrics" + actionLinks={ + <React.Fragment> + <AlertActionLink onClick={(): Promise<QueryObserverResult<ISeries, Error>> => refetch()}> + Retry + </AlertActionLink> + </React.Fragment> + } + > + <p>{error?.message}</p> + </Alert> + ) : data ? ( + <div style={{ height: '300px' }}> + <Chart + startTime={data.startTime} + endTime={data.endTime} + min={data.min} + max={data.max} + options={{ stacked: false, type: 'line', unit: unit }} + labels={data.labels} + series={data.series} + /> + </div> + ) : null} + </CardBody> + </Card> + ); +}; + +export default DetailsMetricsPod; diff --git a/plugins/istio/src/components/panel/details/DetailsTap.tsx b/plugins/istio/src/components/panel/details/DetailsTap.tsx new file mode 100644 index 000000000..0f77b49ba --- /dev/null +++ b/plugins/istio/src/components/panel/details/DetailsTap.tsx @@ -0,0 +1,62 @@ +import { + Card, + CardBody, + DrawerActions, + DrawerCloseButton, + DrawerHead, + DrawerPanelBody, + DrawerPanelContent, +} from '@patternfly/react-core'; +import { TableComposable, TableVariant, Tbody, Td, Tr } from '@patternfly/react-table'; +import React from 'react'; + +import { formatTime, getDirection } from '../../../utils/helpers'; +import { ILogLine } from '../../../utils/interfaces'; +import { Title } from '@kobsio/plugin-core'; + +interface IDetailsTapProps { + line: ILogLine; + close: () => void; +} + +const DetailsTap: React.FunctionComponent<IDetailsTapProps> = ({ line, close }: IDetailsTapProps) => { + return ( + <DrawerPanelContent minSize="50%"> + <DrawerHead> + <Title + title={`${ + line.hasOwnProperty('content.upstream_cluster') ? getDirection(line['content.upstream_cluster']) : '-' + }: ${line.hasOwnProperty('content.authority') ? line['content.authority'] : '-'}`} + subtitle={formatTime(line['time'])} + size="lg" + /> + <DrawerActions style={{ padding: 0 }}> + <DrawerCloseButton onClose={close} /> + </DrawerActions> + </DrawerHead> + + <DrawerPanelBody> + <Card isCompact={true}> + <CardBody> + <TableComposable aria-label="Details" variant={TableVariant.compact} borders={false}> + <Tbody> + {Object.keys(line).map((key) => ( + <Tr key={key}> + <Td noPadding={true} dataLabel="Key"> + <b>{key}</b> + </Td> + <Td className="pf-u-text-wrap pf-u-text-break-word" noPadding={true} dataLabel="Value"> + <div style={{ whiteSpace: 'pre-wrap' }}>{line[key]}</div> + </Td> + </Tr> + ))} + </Tbody> + </TableComposable> + </CardBody> + </Card> + </DrawerPanelBody> + </DrawerPanelContent> + ); +}; + +export default DetailsTap; diff --git a/plugins/istio/src/components/panel/details/DetailsTop.tsx b/plugins/istio/src/components/panel/details/DetailsTop.tsx new file mode 100644 index 000000000..0beaee755 --- /dev/null +++ b/plugins/istio/src/components/panel/details/DetailsTop.tsx @@ -0,0 +1,123 @@ +import { + Alert, + AlertActionLink, + AlertVariant, + DrawerActions, + DrawerCloseButton, + DrawerHead, + DrawerPanelBody, + DrawerPanelContent, + Spinner, +} from '@patternfly/react-core'; +import { QueryObserverResult, useQuery } from 'react-query'; +import React from 'react'; + +import { IPluginTimes, Title } from '@kobsio/plugin-core'; +import { convertMetrics, getDirection } from '../../../utils/helpers'; +import DetailsTopChart from './DetailsTopChart'; +import { ITopDetailsMetrics } from '../../../utils/interfaces'; + +interface IDetailsTopProps { + name: string; + namespace: string; + application: string; + row: string[]; + times: IPluginTimes; + close: () => void; +} + +const DetailsTop: React.FunctionComponent<IDetailsTopProps> = ({ + name, + namespace, + application, + row, + times, + close, +}: IDetailsTopProps) => { + const { isError, isLoading, error, data, refetch } = useQuery<ITopDetailsMetrics, Error>( + ['istio/topdetails', name, namespace, application, row, times], + async () => { + try { + const response = await fetch( + `/api/plugins/istio/topdetails/${name}?timeStart=${times.timeStart}&timeEnd=${ + times.timeEnd + }&application=${application}&namespace=${namespace}&upstreamCluster=${encodeURIComponent( + row[0], + )}&authority=${encodeURIComponent(row[1])}&method=${encodeURIComponent(row[2])}&path=${encodeURIComponent( + row[3], + )}`, + { + method: 'get', + }, + ); + const json = await response.json(); + + if (response.status >= 200 && response.status < 300) { + if (json) { + return convertMetrics(json); + } + + return { + latency: [], + sr: [], + }; + } else { + if (json.error) { + throw new Error(json.error); + } else { + throw new Error('An unknown error occured'); + } + } + } catch (err) { + throw err; + } + }, + ); + + return ( + <DrawerPanelContent minSize="50%"> + <DrawerHead> + <Title + title={`${getDirection(row[0]) || '-'}: ${row[1] || '-'}`} + subtitle={`${row[2] || '-'}: ${row[3] || '-'}`} + size="lg" + /> + <DrawerActions style={{ padding: 0 }}> + <DrawerCloseButton onClose={close} /> + </DrawerActions> + </DrawerHead> + + <DrawerPanelBody> + {isLoading ? ( + <div className="pf-u-text-align-center"> + <Spinner /> + </div> + ) : isError ? ( + <Alert + variant={AlertVariant.danger} + isInline={true} + title="Could not get metrics" + actionLinks={ + <React.Fragment> + <AlertActionLink onClick={(): Promise<QueryObserverResult<ITopDetailsMetrics, Error>> => refetch()}> + Retry + </AlertActionLink> + </React.Fragment> + } + > + <p>{error?.message}</p> + </Alert> + ) : data ? ( + <div> + <DetailsTopChart title="Success Rate" unit="%" series={data.sr} times={times} /> + <p> </p> + <DetailsTopChart title="Latency" unit="ms" series={data.latency} times={times} /> + <p> </p> + </div> + ) : null} + </DrawerPanelBody> + </DrawerPanelContent> + ); +}; + +export default DetailsTop; diff --git a/plugins/istio/src/components/panel/details/DetailsTopChart.tsx b/plugins/istio/src/components/panel/details/DetailsTopChart.tsx new file mode 100644 index 000000000..2b8ddc802 --- /dev/null +++ b/plugins/istio/src/components/panel/details/DetailsTopChart.tsx @@ -0,0 +1,72 @@ +import { Card, CardBody, CardTitle } from '@patternfly/react-core'; +import { ResponsiveLineCanvas, Serie } from '@nivo/line'; +import React from 'react'; + +import { CHART_THEME, COLOR_SCALE, ChartTooltip, IPluginTimes } from '@kobsio/plugin-core'; +import { formatAxisBottom } from '@kobsio/plugin-prometheus'; + +interface IDetailsTopChartProps { + title: string; + unit: string; + series: Serie[]; + times: IPluginTimes; +} + +const DetailsTopChart: React.FunctionComponent<IDetailsTopChartProps> = ({ + title, + unit, + series, + times, +}: IDetailsTopChartProps) => { + return ( + <Card isCompact={true}> + <CardTitle>{title}</CardTitle> + <CardBody> + <div style={{ height: '300px' }}> + <ResponsiveLineCanvas + axisBottom={{ + format: formatAxisBottom(times.timeStart, times.timeEnd), + }} + axisLeft={{ + format: '>-.2f', + legend: unit, + legendOffset: -40, + legendPosition: 'middle', + }} + colors={COLOR_SCALE} + curve="monotoneX" + data={series} + enableArea={false} + enableGridX={false} + enableGridY={true} + enablePoints={false} + xFormat="time:%Y-%m-%d %H:%M:%S" + lineWidth={1} + margin={{ bottom: 25, left: 50, right: 0, top: 0 }} + theme={CHART_THEME} + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + tooltip={(tooltip) => { + const isFirstHalf = + Math.floor(new Date(tooltip.point.data.x).getTime() / 1000) < (times.timeEnd + times.timeStart) / 2; + + return ( + <ChartTooltip + anchor={isFirstHalf ? 'right' : 'left'} + color={tooltip.point.color} + label={`${tooltip.point.serieId}: ${tooltip.point.data.yFormatted} ${unit ? unit : ''}`} + position={[0, 20]} + title={tooltip.point.data.xFormatted.toString()} + /> + ); + }} + xScale={{ type: 'time' }} + yScale={{ max: 'auto', min: 'auto', stacked: false, type: 'linear' }} + yFormat=" >-.4f" + /> + </div> + </CardBody> + </Card> + ); +}; + +export default DetailsTopChart; diff --git a/plugins/istio/src/utils/helpers.ts b/plugins/istio/src/utils/helpers.ts index 15f3e1c0f..c01e2577b 100644 --- a/plugins/istio/src/utils/helpers.ts +++ b/plugins/istio/src/utils/helpers.ts @@ -1,8 +1,10 @@ -import { IPluginTimes, getTimeParams } from '@kobsio/plugin-core'; -import { IOptions } from './interfaces'; +import { Datum } from '@nivo/line'; + +import { IApplicationOptions, IApplicationsOptions, ITopDetailsMetrics } from './interfaces'; +import { getTimeParams } from '@kobsio/plugin-core'; // getApplicationsOptionsFromSearch is used to get the Istio options from a given search location. -export const getApplicationsOptionsFromSearch = (search: string): IOptions => { +export const getApplicationsOptionsFromSearch = (search: string): IApplicationsOptions => { const params = new URLSearchParams(search); const namespaces = params.getAll('namespace'); @@ -13,16 +15,83 @@ export const getApplicationsOptionsFromSearch = (search: string): IOptions => { }; // getApplicationOptionsFromSearch is used to get the Istio options from a given search location. -export const getApplicationOptionsFromSearch = (search: string): IPluginTimes => { +export const getApplicationOptionsFromSearch = (search: string): IApplicationOptions => { const params = new URLSearchParams(search); - return getTimeParams(params); + const view = params.get('view'); + const filterName = params.get('filterName'); + const filterMethod = params.get('filterMethod'); + const filterPath = params.get('filterPath'); + + return { + filters: { + method: filterMethod ? filterMethod : '', + name: filterName ? filterName : '', + path: filterPath ? filterPath : '', + }, + times: getTimeParams(params), + view: view ? view : 'metrics', + }; }; // formatNumber brings the given value returned by Prometheus as string into a format we can use in our ui. export const formatNumber = (value: string, unit = '', dec = 4): string => { - if (value === 'NaN') { + if (value === 'NaN' || value === '') { return '-'; } - return `${Math.round(parseFloat(value) * Math.pow(10, dec)) / Math.pow(10, dec)} ${unit}`; + return formatParsedNumber(parseFloat(value), unit, dec); +}; + +export const formatParsedNumber = (value: number, unit = '', dec = 4): string => { + return `${Math.round(value * Math.pow(10, dec)) / Math.pow(10, dec)} ${unit}`; +}; + +export const getDirection = (upstreamCluster: string): string => { + if (upstreamCluster.startsWith('inbound')) { + return 'INBOUND'; + } else if (upstreamCluster.startsWith('outbound')) { + return 'OUTBOUND'; + } + + return '-'; +}; + +// formatTime formate the given time string. We do not use the formatTime function from the core package, because we +// also want to include milliseconds in the logs timestamp, which we show. +export const formatTime = (time: string): string => { + const d = new Date(time); + return `${d.getFullYear()}-${('0' + (d.getMonth() + 1)).slice(-2)}-${('0' + d.getDate()).slice(-2)} ${( + '0' + d.getHours() + ).slice(-2)}:${('0' + d.getMinutes()).slice(-2)}:${('0' + d.getSeconds()).slice(-2)}.${( + '00' + d.getMilliseconds() + ).slice(-3)}`; +}; + +// convertMetrics converts the returned top metrics from our API into the format needed for the charts. +export const convertMetrics = (rows: [string, number, number, number, number, number][]): ITopDetailsMetrics => { + const sr: Datum[] = []; + const p50: Datum[] = []; + const p90: Datum[] = []; + const p99: Datum[] = []; + + for (const row of rows) { + const d = new Date(row[0]); + sr.push({ x: d, y: row[2] }); + p50.push({ x: d, y: row[3] }); + p90.push({ x: d, y: row[4] }); + p99.push({ x: d, y: row[5] }); + } + + return { + latency: [ + { data: p50, id: 'P50' }, + { data: p90, id: 'P90' }, + { data: p99, id: 'P99' }, + ], + sr: [{ data: sr, id: 'SR' }], + }; +}; + +export const escapeRegExp = (value: string): string => { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }; diff --git a/plugins/istio/src/utils/interfaces.ts b/plugins/istio/src/utils/interfaces.ts index 337b0f5c6..1ca23da3e 100644 --- a/plugins/istio/src/utils/interfaces.ts +++ b/plugins/istio/src/utils/interfaces.ts @@ -1,3 +1,4 @@ +import { Serie } from '@nivo/line'; import cytoscape from 'cytoscape'; import { IPluginTimes } from '@kobsio/plugin-core'; @@ -5,14 +6,29 @@ import { IRowValues } from '@kobsio/plugin-prometheus'; export interface IPluginOptions { prometheus: boolean; + clickhouse: boolean; } -// IOptions is the interface for the options on the Istio page. -export interface IOptions { +// IApplicationsOptions is the interface for the Istio applications page. +export interface IApplicationsOptions { namespaces: string[]; times: IPluginTimes; } +// IApplicationOptions is the interface for the Istio application page. +export interface IApplicationOptions { + view: string; + times: IPluginTimes; + filters: IFilters; +} + +// IFilters is the interface to specify filters for the tab and top view of an Istio application. +export interface IFilters { + name: string; + method: string; + path: string; +} + // IPanelOptions is the interface for the options property for the Istio panel component. export interface IPanelOptions { namespaces?: string[]; @@ -43,3 +59,15 @@ export interface IEdgeData { source: string; target: string; } + +// ILogLine represents a single log line. +export interface ILogLine { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +} + +// ITopDetailsMetrics contains the success rate and the latency series which can be used within our charts. +export interface ITopDetailsMetrics { + sr: Serie[]; + latency: Serie[]; +} diff --git a/plugins/kiali/package.json b/plugins/kiali/package.json index 37ff3e7f2..db84b6564 100644 --- a/plugins/kiali/package.json +++ b/plugins/kiali/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@kobsio/plugin-core": "*", + "@kobsio/plugin-prometheus": "*", "@nivo/line": "^0.73.0", "@patternfly/react-core": "^4.128.2", "@patternfly/react-icons": "^4.10.11", diff --git a/plugins/kiali/src/components/panel/details/Chart.tsx b/plugins/kiali/src/components/panel/details/Chart.tsx index 3788f8039..6c215d750 100644 --- a/plugins/kiali/src/components/panel/details/Chart.tsx +++ b/plugins/kiali/src/components/panel/details/Chart.tsx @@ -5,7 +5,7 @@ import { ResponsiveLineCanvas } from '@nivo/line'; import { CHART_THEME, COLOR_SCALE, ChartTooltip } from '@kobsio/plugin-core'; import { IChart } from '../../../utils/interfaces'; import { IPluginTimes } from '@kobsio/plugin-core'; -import { formatAxisBottom } from '../../../utils/helpers'; +import { formatAxisBottom } from '@kobsio/plugin-prometheus'; interface IChartProps { times: IPluginTimes; @@ -20,7 +20,7 @@ export const Chart: React.FunctionComponent<IChartProps> = ({ times, chart }: IC <div style={{ height: '300px', width: '100%' }}> <ResponsiveLineCanvas axisBottom={{ - format: formatAxisBottom(times), + format: formatAxisBottom(times.timeStart, times.timeEnd), }} axisLeft={{ format: '>-.2f', @@ -40,15 +40,22 @@ export const Chart: React.FunctionComponent<IChartProps> = ({ times, chart }: IC margin={{ bottom: 25, left: 50, right: 0, top: 0 }} theme={CHART_THEME} // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - tooltip={(tooltip) => ( - <ChartTooltip - color={tooltip.point.color} - label={`${chart.series.filter((serie) => serie.id === tooltip.point.serieId)[0].label}: ${ - tooltip.point.data.yFormatted - } ${chart.unit ? chart.unit : ''}`} - title={tooltip.point.data.xFormatted.toString()} - /> - )} + tooltip={(tooltip) => { + const isFirstHalf = + Math.floor(new Date(tooltip.point.data.x).getTime() / 1000) < (times.timeEnd + times.timeStart) / 2; + + return ( + <ChartTooltip + anchor={isFirstHalf ? 'right' : 'left'} + color={tooltip.point.color} + label={`${chart.series.filter((serie) => serie.id === tooltip.point.serieId)[0].label}: ${ + tooltip.point.data.yFormatted + } ${chart.unit ? chart.unit : ''}`} + position={[0, 20]} + title={tooltip.point.data.xFormatted.toString()} + /> + ); + }} xScale={{ type: 'time' }} yScale={{ max: 'auto', min: 'auto', stacked: false, type: 'linear' }} yFormat=" >-.2f" diff --git a/plugins/kiali/src/utils/helpers.ts b/plugins/kiali/src/utils/helpers.ts index b0c5db440..30f95e296 100644 --- a/plugins/kiali/src/utils/helpers.ts +++ b/plugins/kiali/src/utils/helpers.ts @@ -1,5 +1,5 @@ import { IMetric, INodeData, IOptions, ISerie } from './interfaces'; -import { IPluginTimes, getTimeParams } from '@kobsio/plugin-core'; +import { getTimeParams } from '@kobsio/plugin-core'; // getOptionsFromSearch is used to get the Kiali options from a given search location. export const getOptionsFromSearch = (search: string): IOptions => { @@ -63,19 +63,6 @@ const getMetricLabel = (metric: IMetric): string => { return ''; }; -// formatAxisBottom calculates the format for the bottom axis based on the specified start and end time. -export const formatAxisBottom = (times: IPluginTimes): string => { - if (times.timeEnd - times.timeStart < 3600) { - return '%H:%M:%S'; - } else if (times.timeEnd - times.timeStart < 86400) { - return '%H:%M'; - } else if (times.timeEnd - times.timeStart < 604800) { - return '%m-%d %H:%M'; - } - - return '%m-%d'; -}; - export const getSteps = (start: number, end: number): string => { const seconds = end - start; diff --git a/plugins/prometheus/src/index.ts b/plugins/prometheus/src/index.ts index cc6749ae1..1fffb7427 100644 --- a/plugins/prometheus/src/index.ts +++ b/plugins/prometheus/src/index.ts @@ -18,3 +18,5 @@ const prometheusPlugin: IPluginComponents = { export default prometheusPlugin; export * from './utils/interfaces'; +export * from './utils/helpers'; +export * from './components/panel/Chart'; diff --git a/plugins/resources/src/components/panel/PanelListItem.tsx b/plugins/resources/src/components/panel/PanelListItem.tsx index 8fe35ff0a..402157090 100644 --- a/plugins/resources/src/components/panel/PanelListItem.tsx +++ b/plugins/resources/src/components/panel/PanelListItem.tsx @@ -51,7 +51,7 @@ const PanelListItem: React.FunctionComponent<IPanelListItemProps> = ({ }, ); - // refetchhWithDelay is used to call the refetch function to get the resource, but with a delay of 3 seconde. This is + // refetchhWithDelay is used to call the refetch function to get the resource, but with a delay of 3 seconds. This is // required, because sometime the Kubenretes isn't that fast after an action (edit, delete, ...) was triggered. const refetchhWithDelay = (): void => { setTimeout(() => { diff --git a/yarn.lock b/yarn.lock index 630612398..f3398e329 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2185,6 +2185,15 @@ "@react-spring/web" "9.2.4" lodash "^4.17.21" +"@nivo/annotations@0.74.0": + version "0.74.0" + resolved "https://registry.yarnpkg.com/@nivo/annotations/-/annotations-0.74.0.tgz#f4a3474fdf8812c3812c30e08d3277e209bec0f6" + integrity sha512-nxZLKDi9YEy2zZUsOtbYL/2oAgsxK5SVZ1P3Csll+cQ96uLU6sU7jmb67AwK0nDbYk7BD3sZf/O/A9r/MCK4Ow== + dependencies: + "@nivo/colors" "0.74.0" + "@react-spring/web" "9.2.6" + lodash "^4.17.21" + "@nivo/arcs@0.73.0": version "0.73.0" resolved "https://registry.yarnpkg.com/@nivo/arcs/-/arcs-0.73.0.tgz#2211a0c41e8f6ed67374aeebdad607fbb3a1db2f" @@ -2205,6 +2214,16 @@ d3-time "^1.0.11" d3-time-format "^3.0.0" +"@nivo/axes@0.74.0": + version "0.74.0" + resolved "https://registry.yarnpkg.com/@nivo/axes/-/axes-0.74.0.tgz#cf7cf2277b7aca5449a040ddf3e0cf9891971199" + integrity sha512-27o1H+Br0AaeUTiRhy7OebqzYEWr1xznHOxd+Hn2Xz9kK1alGBiPgwXrkXV0Q9CtrsroQFnX2QR3JxRgOtC5fA== + dependencies: + "@nivo/scales" "0.74.0" + "@react-spring/web" "9.2.6" + d3-format "^1.4.4" + d3-time-format "^3.0.0" + "@nivo/bar@^0.73.1": version "0.73.1" resolved "https://registry.yarnpkg.com/@nivo/bar/-/bar-0.73.1.tgz#86e59e25af151ead0c09298ec9caf133f38e957e" @@ -2233,6 +2252,17 @@ lodash "^4.17.21" react-motion "^0.5.2" +"@nivo/colors@0.74.0": + version "0.74.0" + resolved "https://registry.yarnpkg.com/@nivo/colors/-/colors-0.74.0.tgz#29d1e7c6f3bcab4e872a168651b3a90cfba03a4f" + integrity sha512-5ClckmBm3x2XdJqHMylr6erY+scEL/twoGVfyXak/L+AIhL+Gf9PQxyxyfl3Lbtc3SPeAQe0ZAO1+VrmTn7qlA== + dependencies: + d3-color "^2.0.0" + d3-scale "^3.2.3" + d3-scale-chromatic "^2.0.0" + lodash "^4.17.21" + react-motion "^0.5.2" + "@nivo/core@^0.73.0": version "0.73.0" resolved "https://registry.yarnpkg.com/@nivo/core/-/core-0.73.0.tgz#58fac20c8cd7eac12bfdc96619554764ca225cdf" @@ -2256,6 +2286,11 @@ resolved "https://registry.yarnpkg.com/@nivo/legends/-/legends-0.73.0.tgz#ef344038c4ff03249ffffebaf14412d012004fda" integrity sha512-OWyu3U6PJL2VGlAfoz6nTU4opXHlR0yp0h+0Q0rf/hMKQLiew6NmecKcR1Nx2Qw4dJHgOnZRXqQ6vQrhcNV3WQ== +"@nivo/legends@0.74.0": + version "0.74.0" + resolved "https://registry.yarnpkg.com/@nivo/legends/-/legends-0.74.0.tgz#8e5e04b2a3f980c2073a394d94c4d89fa8bc8724" + integrity sha512-Bfk392ngre1C8UaGoymwqK0acjjzuk0cglUSNsr0z8BAUQIVGUPthtfcxbq/yUYGJL/cxWky2QKxi9r3C0FbmA== + "@nivo/line@^0.73.0": version "0.73.0" resolved "https://registry.yarnpkg.com/@nivo/line/-/line-0.73.0.tgz#8d77963d0ff014138b2201cc0836f0a12593225f" @@ -2271,6 +2306,21 @@ "@react-spring/web" "9.2.4" d3-shape "^1.3.5" +"@nivo/line@^0.74.0": + version "0.74.0" + resolved "https://registry.yarnpkg.com/@nivo/line/-/line-0.74.0.tgz#f1f430d64a81d2fe1a5fd49e5cfaa61242066927" + integrity sha512-uJssLII1UTfxrZkPrkki054LFUpSKeqS35ttwK6VLvyqs5r3SrSXn223vDRNaaxuop5oT/L3APUJQwQDqUcj3w== + dependencies: + "@nivo/annotations" "0.74.0" + "@nivo/axes" "0.74.0" + "@nivo/colors" "0.74.0" + "@nivo/legends" "0.74.0" + "@nivo/scales" "0.74.0" + "@nivo/tooltip" "0.74.0" + "@nivo/voronoi" "0.74.0" + "@react-spring/web" "9.2.6" + d3-shape "^1.3.5" + "@nivo/pie@^0.73.0": version "0.73.0" resolved "https://registry.yarnpkg.com/@nivo/pie/-/pie-0.73.0.tgz#4370bfdaaded5b0ba159cc544548b02373baf55a" @@ -2299,6 +2349,16 @@ d3-time-format "^3.0.0" lodash "^4.17.21" +"@nivo/scales@0.74.0": + version "0.74.0" + resolved "https://registry.yarnpkg.com/@nivo/scales/-/scales-0.74.0.tgz#ede12b899da9e3aee7921ebce40f227e670a430d" + integrity sha512-5mER71NgZGdgs8X2PgilBpAWMMGtTXrUuYOBQWDKDMgtc83MU+mphhiYfLv5e6ViZyUB5ebfEkfeIgStLqrcEA== + dependencies: + d3-scale "^3.2.3" + d3-time "^1.0.11" + d3-time-format "^3.0.0" + lodash "^4.17.21" + "@nivo/scatterplot@^0.73.0": version "0.73.0" resolved "https://registry.yarnpkg.com/@nivo/scatterplot/-/scatterplot-0.73.0.tgz#1d81ee364b6af427948055349f719455494b1ea8" @@ -2323,6 +2383,13 @@ dependencies: "@react-spring/web" "9.2.4" +"@nivo/tooltip@0.74.0": + version "0.74.0" + resolved "https://registry.yarnpkg.com/@nivo/tooltip/-/tooltip-0.74.0.tgz#60d94b0fecc2fc179ada3efa380e7e456982b4a5" + integrity sha512-h3PUgNFF5HUeQFfx19MWS1uGK8iUDymZNY+5PyaCWDFT+0/ldXBu8uw5WYRui2KwNdTym6F0E/aT7JKczDd85w== + dependencies: + "@react-spring/web" "9.2.6" + "@nivo/voronoi@0.73.0": version "0.73.0" resolved "https://registry.yarnpkg.com/@nivo/voronoi/-/voronoi-0.73.0.tgz#858aa5340c93bc299e07938b1e6770394ca7e9c9" @@ -2331,6 +2398,14 @@ d3-delaunay "^5.3.0" d3-scale "^3.2.3" +"@nivo/voronoi@0.74.0": + version "0.74.0" + resolved "https://registry.yarnpkg.com/@nivo/voronoi/-/voronoi-0.74.0.tgz#4b427955ddabd86934a2bbb95a62ff53ee97c575" + integrity sha512-Q3267T1+Tlufn8LbmSYnO8x9gL+h/iwH2Uqc5CENHSZu2KPD0PB82vxpQnbDVhjadulI0rlrPA9fU3VY3q1zKg== + dependencies: + d3-delaunay "^5.3.0" + d3-scale "^3.2.3" + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -2619,6 +2694,14 @@ "@react-spring/shared" "~9.2.5-beta.0" "@react-spring/types" "~9.2.5-beta.0" +"@react-spring/animated@~9.2.6-beta.0": + version "9.2.6" + resolved "https://registry.yarnpkg.com/@react-spring/animated/-/animated-9.2.6.tgz#58f30fb75d8bfb7ccbc156cfd6b974a8f3dfd54e" + integrity sha512-xjL6nmixYNDvnpTs1FFMsMfSC0tURwPCU3b2jWNriYGLfwZ7c/TcyaEZA7yiNnmdFnuR3f3Z27AqIgaFC083Cw== + dependencies: + "@react-spring/shared" "~9.2.6-beta.0" + "@react-spring/types" "~9.2.6-beta.0" + "@react-spring/core@~9.2.0": version "9.2.5" resolved "https://registry.yarnpkg.com/@react-spring/core/-/core-9.2.5.tgz#fd0cae8e291467dcb94d5dc4eabe43e07cca9697" @@ -2628,11 +2711,25 @@ "@react-spring/shared" "~9.2.5-beta.0" "@react-spring/types" "~9.2.5-beta.0" +"@react-spring/core@~9.2.6-beta.0": + version "9.2.6" + resolved "https://registry.yarnpkg.com/@react-spring/core/-/core-9.2.6.tgz#ae22338fe55d070caf03abb4293b5519ba620d93" + integrity sha512-uPHUxmu+w6mHJrfQTMtmGJ8iZEwiVxz9kH7dRyk69bkZJt9z+w0Oj3UF4J3VcECZsbm3HRhN2ogXSAaqGjwhQw== + dependencies: + "@react-spring/animated" "~9.2.6-beta.0" + "@react-spring/shared" "~9.2.6-beta.0" + "@react-spring/types" "~9.2.6-beta.0" + "@react-spring/rafz@~9.2.5-beta.0": version "9.2.5" resolved "https://registry.yarnpkg.com/@react-spring/rafz/-/rafz-9.2.5.tgz#517b6bf6407dd791719e5aae11c18fd321c08af1" integrity sha512-FZdbgcBMF1DM/eCnHZ28nHUG984gqcZHWlz2aIfj5TikPTzgVYDECCW/Pvt3ncHLTxikjYn2wvDV3/Q68yqv8A== +"@react-spring/rafz@~9.2.6-beta.0": + version "9.2.6" + resolved "https://registry.yarnpkg.com/@react-spring/rafz/-/rafz-9.2.6.tgz#d97484003875bf5fb5e6ec22dee97cc208363e48" + integrity sha512-62SivLKEpo7EfHPkxO5J3g9Cr9LF6+1A1RVOMJhkcpEYtbdbmma/d63Xp8qpMPEpk7uuWxaTb6jjyxW33pW3sg== + "@react-spring/shared@~9.2.0", "@react-spring/shared@~9.2.5-beta.0": version "9.2.5" resolved "https://registry.yarnpkg.com/@react-spring/shared/-/shared-9.2.5.tgz#ce96cd1063bd644e820b19d9f3ebce8f6077b872" @@ -2641,11 +2738,24 @@ "@react-spring/rafz" "~9.2.5-beta.0" "@react-spring/types" "~9.2.5-beta.0" +"@react-spring/shared@~9.2.6-beta.0": + version "9.2.6" + resolved "https://registry.yarnpkg.com/@react-spring/shared/-/shared-9.2.6.tgz#2c84e62cc0cfbbbbeb5546acd46c1f4b248bc562" + integrity sha512-Qrm9fopKG/RxZ3Rw+4euhrpnB3uXSyiON9skHbcBfmkkzagpkUR66MX1YLrhHw0UchcZuSDnXs0Lonzt1rpWag== + dependencies: + "@react-spring/rafz" "~9.2.6-beta.0" + "@react-spring/types" "~9.2.6-beta.0" + "@react-spring/types@~9.2.0", "@react-spring/types@~9.2.5-beta.0": version "9.2.5" resolved "https://registry.yarnpkg.com/@react-spring/types/-/types-9.2.5.tgz#14eeca9ed7d5beed8c3fc943ee8f365c5c9fa635" integrity sha512-ayitxzSUGO4MTQ6VOeNgUviTV/8nxjwGq6Ie+pFgv6JUlOecwdzo2/apEeHN6ae9tbcxQJx6nuDw/yb590M8Uw== +"@react-spring/types@~9.2.6-beta.0": + version "9.2.6" + resolved "https://registry.yarnpkg.com/@react-spring/types/-/types-9.2.6.tgz#f60722fcf9f8492ae16d0bdc47f0ea3c2a16d2cf" + integrity sha512-l7mCw182DtDMnCI8CB9orgTAEoFZRtdQ6aS6YeEAqYcy3nQZPmPggIHH9DxyLw7n7vBPRSzu9gCvUMgXKpTflg== + "@react-spring/web@9.2.4": version "9.2.4" resolved "https://registry.yarnpkg.com/@react-spring/web/-/web-9.2.4.tgz#c6d5464a954bfd0d7bc90117050f796a95ebfa08" @@ -2656,6 +2766,16 @@ "@react-spring/shared" "~9.2.0" "@react-spring/types" "~9.2.0" +"@react-spring/web@9.2.6": + version "9.2.6" + resolved "https://registry.yarnpkg.com/@react-spring/web/-/web-9.2.6.tgz#c4fba69e1b1b43bd1d6a62346530cfb07f2be09b" + integrity sha512-0HkRsEYR/CO3Uw46FWDWaF2wg2rUXcWE2R9AoZXthEYLUn5w9uE1mf2Jel7BxBxWGQ73owkqSQv+klA1Hb+ViQ== + dependencies: + "@react-spring/animated" "~9.2.6-beta.0" + "@react-spring/core" "~9.2.6-beta.0" + "@react-spring/shared" "~9.2.6-beta.0" + "@react-spring/types" "~9.2.6-beta.0" + "@rollup/plugin-node-resolve@^7.1.1": version "7.1.3" resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-7.1.3.tgz#80de384edfbd7bfc9101164910f86078151a3eca" @@ -5621,7 +5741,7 @@ cytoscape-node-html-label@^1.2.2: resolved "https://registry.yarnpkg.com/cytoscape-node-html-label/-/cytoscape-node-html-label-1.2.2.tgz#cad4942a52be2075f55521b3ea376daadc67d65f" integrity sha512-oUVwrlsIlaJJ8QrQFSMdv3uXVXPg6tMH/Tfofr8JuZIovqI4fPqBi6sQgCMcVpS6k9Td0TTjowBsNRw32CESWg== -cytoscape@^3.19.0, cytoscape@^3.2.19: +cytoscape@^3.19.0, cytoscape@^3.19.1, cytoscape@^3.2.19: version "3.19.1" resolved "https://registry.yarnpkg.com/cytoscape/-/cytoscape-3.19.1.tgz#034fb5e5de7e6b9b7c948a48da8ab7037d430f69" integrity sha512-fQSymoCzmDF5dejZqv94WXQUnI3cVZfaHWFQR+Q9RhJ6LzEs7dtkwgFYaoklsbXcrXz0uCGx4i3vQ0FiuOUu9Q==