From 20e9d8dbe1de162e69db08aa20661c922fd970de Mon Sep 17 00:00:00 2001 From: ricoberger Date: Sun, 17 Oct 2021 10:46:36 +0200 Subject: [PATCH] [istio] Add top and tap command and details Next to the metrics view, the Istio plugin now also supports a top and tap command, which used an existing Clickhouse instance. The top command uses the Istio access logs from Clickhouse to create an aggregation based on the path and method of an request. The tap command can be used to view all requests. Both commands are supporting an live update feature to refresh the view. All three views (metrics, top and tap) are supporting details now, this means when a user selects a metrics or a log line a drawer panel is opened with the success rate, requests per seconde and latency metrics for the selected time range. NOTE: As we did it with the Prometheus plugin in #177 we are now also exposing the Clickhouse instances from the plugin, so that we can use the instance specified in the configuration within the Istio plugin. --- CHANGELOG.md | 1 + cmd/kobs/plugins/plugins.go | 4 +- docs/resources/resources.md | 2 +- .../applications/src/components/page/Page.tsx | 2 +- plugins/clickhouse/clickhouse.go | 6 +- plugins/clickhouse/pkg/instance/instance.go | 45 ++- plugins/clickhouse/pkg/instance/logs.go | 10 +- plugins/flux/src/components/page/PageList.tsx | 2 +- .../flux/src/components/panel/PanelList.tsx | 2 +- plugins/istio/istio.go | 216 ++++++++++- plugins/istio/package.json | 7 + plugins/istio/pkg/instance/instance.go | 202 +++++++++- .../istio/src/components/page/Application.tsx | 161 ++++---- .../components/page/ApplicationActions.tsx | 148 ++++++++ .../components/page/ApplicationMetrics.tsx | 105 ++++++ .../src/components/page/ApplicationTap.tsx | 94 +++++ .../components/page/ApplicationToolbar.tsx | 40 +- .../src/components/page/ApplicationTop.tsx | 94 +++++ .../src/components/page/Applications.tsx | 8 +- .../components/page/ApplicationsToolbar.tsx | 6 +- plugins/istio/src/components/page/Page.tsx | 1 + .../src/components/panel/MetricsTable.tsx | 29 +- plugins/istio/src/components/panel/Tap.tsx | 153 ++++++++ plugins/istio/src/components/panel/Top.tsx | 240 ++++++++++++ .../istio/src/components/panel/Topology.tsx | 17 +- .../src/components/panel/TopologyGraph.tsx | 30 +- .../panel/details/DetailsMetrics.tsx | 348 ++++++++++++++++++ .../panel/details/DetailsMetricsMetric.tsx | 124 +++++++ .../panel/details/DetailsMetricsPod.tsx | 99 +++++ .../components/panel/details/DetailsTap.tsx | 62 ++++ .../components/panel/details/DetailsTop.tsx | 123 +++++++ .../panel/details/DetailsTopChart.tsx | 72 ++++ plugins/istio/src/utils/helpers.ts | 83 ++++- plugins/istio/src/utils/interfaces.ts | 32 +- plugins/kiali/package.json | 1 + .../src/components/panel/details/Chart.tsx | 29 +- plugins/kiali/src/utils/helpers.ts | 15 +- plugins/prometheus/src/index.ts | 2 + .../src/components/panel/PanelListItem.tsx | 2 +- yarn.lock | 122 +++++- 40 files changed, 2563 insertions(+), 176 deletions(-) create mode 100644 plugins/istio/src/components/page/ApplicationActions.tsx create mode 100644 plugins/istio/src/components/page/ApplicationMetrics.tsx create mode 100644 plugins/istio/src/components/page/ApplicationTap.tsx create mode 100644 plugins/istio/src/components/page/ApplicationTop.tsx create mode 100644 plugins/istio/src/components/panel/Tap.tsx create mode 100644 plugins/istio/src/components/panel/Top.tsx create mode 100644 plugins/istio/src/components/panel/details/DetailsMetrics.tsx create mode 100644 plugins/istio/src/components/panel/details/DetailsMetricsMetric.tsx create mode 100644 plugins/istio/src/components/panel/details/DetailsMetricsPod.tsx create mode 100644 plugins/istio/src/components/panel/details/DetailsTap.tsx create mode 100644 plugins/istio/src/components/panel/details/DetailsTop.tsx create mode 100644 plugins/istio/src/components/panel/details/DetailsTopChart.tsx 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==