From 04fe299739d45f71c18ddca9fab684aafa4d6e57 Mon Sep 17 00:00:00 2001 From: ricoberger Date: Mon, 22 Mar 2021 18:38:56 +0100 Subject: [PATCH] Refactor cluster and application handling Refactor the cluster and application handling, for a better development experience. The Go code for the clusters and applications lives now in a plugins folder, to prepare the gRPC server for our new plugins concept. All React components for the clusters and applications were reworked, because the old components, were following an ugly naming schme. Also some of the components were to complex from the logic they had to do. The new components should improve the experience with the frontend code for kobs. --- CHANGELOG.md | 3 +- CONTRIBUTING.md | 2 +- Makefile | 38 +- app/.eslintignore | 2 +- app/package.json | 4 +- app/public/img/datasources/jaeger.png | Bin 0 -> 92280 bytes app/src/App.tsx | 38 +- app/src/app.css | 118 - app/src/components/Editor.tsx | 48 + app/src/components/Home.tsx | 26 + app/src/components/HomeItem.tsx | 29 + app/src/components/{shared => }/Title.tsx | 3 + .../components/applications/Application.tsx | 139 +- .../applications/ApplicationDetails.tsx | 72 + .../applications/ApplicationDetailsLink.tsx | 29 + .../applications/ApplicationGallery.tsx | 28 + .../applications/ApplicationItem.tsx | 28 + .../applications/ApplicationTabs.tsx | 34 + .../applications/ApplicationTabsContent.tsx | 71 + .../components/applications/Applications.tsx | 111 +- .../applications/ApplicationsToolbar.tsx | 100 + .../applications/details/DetailsLink.tsx | 31 - .../applications/details/DrawerPanel.tsx | 80 - .../applications/details/NotDefined.tsx | 34 - .../components/applications/details/Tabs.tsx | 55 - .../applications/details/TabsContent.tsx | 132 - .../details/logs/Elasticsearch.tsx | 118 - .../applications/details/logs/Logs.tsx | 126 - .../applications/details/logs/Toolbar.tsx | 91 - .../applications/details/metrics/Chart.tsx | 140 - .../applications/details/metrics/Charts.tsx | 69 - .../applications/details/metrics/Metrics.tsx | 130 - .../applications/details/metrics/Options.tsx | 196 - .../applications/details/metrics/Toolbar.tsx | 137 - .../applications/details/metrics/Variable.tsx | 41 - .../details/metrics/charts/Actions.tsx | 48 - .../details/metrics/charts/Default.tsx | 107 - .../details/metrics/charts/Sparkline.tsx | 42 - .../details/resources/Resource.tsx | 72 - .../details/resources/Resources.tsx | 75 - .../components/applications/overview/Card.tsx | 85 - .../applications/overview/Chart.tsx | 48 - app/src/components/datasources/Datasource.tsx | 49 - .../components/datasources/Datasources.tsx | 105 - app/src/components/datasources/Item.tsx | 42 - .../datasources/elasticsearch/Buckets.tsx | 83 - .../datasources/elasticsearch/Document.tsx | 58 - .../datasources/elasticsearch/Documents.tsx | 54 - .../elasticsearch/Elasticsearch.tsx | 279 -- .../datasources/elasticsearch/Fields.tsx | 49 - .../datasources/elasticsearch/helpers.ts | 54 - .../datasources/prometheus/Data.tsx | 87 - .../datasources/prometheus/Prometheus.tsx | 169 - app/src/components/overview/Overview.tsx | 53 - app/src/components/overview/gallery/Item.tsx | 28 - .../components/resources/ResourceDetails.tsx | 105 + .../{drawer/Events.tsx => ResourceEvents.tsx} | 93 +- app/src/components/resources/Resources.tsx | 137 +- .../components/resources/ResourcesList.tsx | 67 + .../resources/ResourcesListItem.tsx | 80 + .../components/resources/ResourcesToolbar.tsx | 126 + .../resources/ToolbarItemClusters.tsx | 36 + .../resources/ToolbarItemNamespaces.tsx | 70 + .../resources/ToolbarItemResources.tsx | 44 + .../resources/drawer/DrawerPanel.tsx | 63 - app/src/components/resources/drawer/Yaml.tsx | 33 - .../components/resources/shared/Filter.tsx | 202 - .../components/shared/EmptyStateSpinner.tsx | 24 - app/src/components/shared/HeaderLogo.tsx | 16 - app/src/context/ClustersContext.tsx | 138 + app/src/generated/proto/application_pb.d.ts | 331 -- app/src/generated/proto/application_pb.js | 2616 ------------ .../proto/datasources_grpc_web_pb.js | 556 --- app/src/generated/proto/datasources_pb.d.ts | 456 --- app/src/generated/proto/datasources_pb.js | 3583 ----------------- .../proto/datasources_pb_service.d.ts | 158 - .../generated/proto/datasources_pb_service.js | 261 -- app/src/proto/application_pb.d.ts | 121 + app/src/proto/application_pb.js | 942 +++++ .../proto/application_pb_service.d.ts | 0 .../proto/application_pb_service.js | 0 .../proto/clusters_grpc_web_pb.js | 80 + .../{generated => }/proto/clusters_pb.d.ts | 112 + app/src/{generated => }/proto/clusters_pb.js | 1857 ++++++--- .../proto/clusters_pb_service.d.ts | 19 + .../proto/clusters_pb_service.js | 40 + app/src/utils/constants.ts | 6 +- app/src/utils/proto.ts | 82 - .../helpers.tsx => utils/resources.tsx} | 90 +- app/yarn.lock | 48 +- cmd/kobs/kobs.go | 8 +- deploy/docker/kobs/config.yaml | 14 - .../kustomize/crds/kobs.io_applications.yaml | 186 +- go.mod | 5 +- go.sum | 216 +- pkg/api/api.go | 19 +- pkg/api/datasources/datasource/datasource.go | 51 - .../datasource/elasticsearch/elasticsearch.go | 199 - .../datasource/prometheus/helpers.go | 66 - .../datasource/prometheus/prometheus.go | 255 -- .../datasource/shared/roundtripper.go | 42 - pkg/api/datasources/datasources.go | 156 - .../application}/apis/application/register.go | 0 .../apis/application/v1alpha1/doc.go | 0 .../apis/application/v1alpha1/register.go | 2 +- .../apis/application/v1alpha1/types.go | 2 +- .../v1alpha1/zz_generated.deepcopy.go | 0 .../clientset/versioned/clientset.go | 2 +- .../application}/clientset/versioned/doc.go | 0 .../versioned/fake/clientset_generated.go | 6 +- .../clientset/versioned/fake/doc.go | 0 .../clientset/versioned/fake/register.go | 2 +- .../clientset/versioned/scheme/doc.go | 0 .../clientset/versioned/scheme/register.go | 2 +- .../typed/application/v1alpha1/application.go | 4 +- .../v1alpha1/application_client.go | 4 +- .../typed/application/v1alpha1/doc.go | 0 .../typed/application/v1alpha1/fake/doc.go | 0 .../v1alpha1/fake/fake_application.go | 2 +- .../v1alpha1/fake/fake_application_client.go | 2 +- .../v1alpha1/generated_expansion.go | 0 .../externalversions/application/interface.go | 4 +- .../application/v1alpha1/application.go | 8 +- .../application/v1alpha1/interface.go | 2 +- .../informers/externalversions/factory.go | 6 +- .../informers/externalversions/generic.go | 2 +- .../internalinterfaces/factory_interfaces.go | 2 +- .../application/v1alpha1/application.go | 2 +- .../v1alpha1/expansion_generated.go | 0 .../application/proto/application.pb.go | 425 ++ .../proto/application_deepcopy.gen.go | 99 + .../{ => plugins}/clusters/cluster/cluster.go | 149 +- pkg/api/{ => plugins}/clusters/clusters.go | 113 +- .../plugins/clusters}/proto/clusters.pb.go | 588 ++- .../clusters}/proto/clusters_deepcopy.gen.go | 85 + .../clusters}/proto/clusters_grpc.pb.go | 36 + .../clusters/provider/incluster/incluster.go | 4 +- .../provider/kubeconfig/kubeconfig.go | 4 +- .../clusters/provider/provider.go | 8 +- pkg/generated/proto/application.pb.go | 1020 ----- .../proto/application_deepcopy.gen.go | 225 -- pkg/generated/proto/datasources.pb.go | 1471 ------- .../proto/datasources_deepcopy.gen.go | 372 -- pkg/generated/proto/datasources_grpc.pb.go | 281 -- proto/application.proto | 108 +- proto/clusters.proto | 36 +- proto/datasources.proto | 139 - 147 files changed, 5640 insertions(+), 17276 deletions(-) create mode 100644 app/public/img/datasources/jaeger.png create mode 100644 app/src/components/Editor.tsx create mode 100644 app/src/components/Home.tsx create mode 100644 app/src/components/HomeItem.tsx rename app/src/components/{shared => }/Title.tsx (73%) create mode 100644 app/src/components/applications/ApplicationDetails.tsx create mode 100644 app/src/components/applications/ApplicationDetailsLink.tsx create mode 100644 app/src/components/applications/ApplicationGallery.tsx create mode 100644 app/src/components/applications/ApplicationItem.tsx create mode 100644 app/src/components/applications/ApplicationTabs.tsx create mode 100644 app/src/components/applications/ApplicationTabsContent.tsx create mode 100644 app/src/components/applications/ApplicationsToolbar.tsx delete mode 100644 app/src/components/applications/details/DetailsLink.tsx delete mode 100644 app/src/components/applications/details/DrawerPanel.tsx delete mode 100644 app/src/components/applications/details/NotDefined.tsx delete mode 100644 app/src/components/applications/details/Tabs.tsx delete mode 100644 app/src/components/applications/details/TabsContent.tsx delete mode 100644 app/src/components/applications/details/logs/Elasticsearch.tsx delete mode 100644 app/src/components/applications/details/logs/Logs.tsx delete mode 100644 app/src/components/applications/details/logs/Toolbar.tsx delete mode 100644 app/src/components/applications/details/metrics/Chart.tsx delete mode 100644 app/src/components/applications/details/metrics/Charts.tsx delete mode 100644 app/src/components/applications/details/metrics/Metrics.tsx delete mode 100644 app/src/components/applications/details/metrics/Options.tsx delete mode 100644 app/src/components/applications/details/metrics/Toolbar.tsx delete mode 100644 app/src/components/applications/details/metrics/Variable.tsx delete mode 100644 app/src/components/applications/details/metrics/charts/Actions.tsx delete mode 100644 app/src/components/applications/details/metrics/charts/Default.tsx delete mode 100644 app/src/components/applications/details/metrics/charts/Sparkline.tsx delete mode 100644 app/src/components/applications/details/resources/Resource.tsx delete mode 100644 app/src/components/applications/details/resources/Resources.tsx delete mode 100644 app/src/components/applications/overview/Card.tsx delete mode 100644 app/src/components/applications/overview/Chart.tsx delete mode 100644 app/src/components/datasources/Datasource.tsx delete mode 100644 app/src/components/datasources/Datasources.tsx delete mode 100644 app/src/components/datasources/Item.tsx delete mode 100644 app/src/components/datasources/elasticsearch/Buckets.tsx delete mode 100644 app/src/components/datasources/elasticsearch/Document.tsx delete mode 100644 app/src/components/datasources/elasticsearch/Documents.tsx delete mode 100644 app/src/components/datasources/elasticsearch/Elasticsearch.tsx delete mode 100644 app/src/components/datasources/elasticsearch/Fields.tsx delete mode 100644 app/src/components/datasources/elasticsearch/helpers.ts delete mode 100644 app/src/components/datasources/prometheus/Data.tsx delete mode 100644 app/src/components/datasources/prometheus/Prometheus.tsx delete mode 100644 app/src/components/overview/Overview.tsx delete mode 100644 app/src/components/overview/gallery/Item.tsx create mode 100644 app/src/components/resources/ResourceDetails.tsx rename app/src/components/resources/{drawer/Events.tsx => ResourceEvents.tsx} (52%) create mode 100644 app/src/components/resources/ResourcesList.tsx create mode 100644 app/src/components/resources/ResourcesListItem.tsx create mode 100644 app/src/components/resources/ResourcesToolbar.tsx create mode 100644 app/src/components/resources/ToolbarItemClusters.tsx create mode 100644 app/src/components/resources/ToolbarItemNamespaces.tsx create mode 100644 app/src/components/resources/ToolbarItemResources.tsx delete mode 100644 app/src/components/resources/drawer/DrawerPanel.tsx delete mode 100644 app/src/components/resources/drawer/Yaml.tsx delete mode 100644 app/src/components/resources/shared/Filter.tsx delete mode 100644 app/src/components/shared/EmptyStateSpinner.tsx delete mode 100644 app/src/components/shared/HeaderLogo.tsx create mode 100644 app/src/context/ClustersContext.tsx delete mode 100644 app/src/generated/proto/application_pb.d.ts delete mode 100644 app/src/generated/proto/application_pb.js delete mode 100644 app/src/generated/proto/datasources_grpc_web_pb.js delete mode 100644 app/src/generated/proto/datasources_pb.d.ts delete mode 100644 app/src/generated/proto/datasources_pb.js delete mode 100644 app/src/generated/proto/datasources_pb_service.d.ts delete mode 100644 app/src/generated/proto/datasources_pb_service.js create mode 100644 app/src/proto/application_pb.d.ts create mode 100644 app/src/proto/application_pb.js rename app/src/{generated => }/proto/application_pb_service.d.ts (100%) rename app/src/{generated => }/proto/application_pb_service.js (100%) rename app/src/{generated => }/proto/clusters_grpc_web_pb.js (85%) rename app/src/{generated => }/proto/clusters_pb.d.ts (75%) rename app/src/{generated => }/proto/clusters_pb.js (73%) rename app/src/{generated => }/proto/clusters_pb_service.d.ts (88%) rename app/src/{generated => }/proto/clusters_pb_service.js (84%) delete mode 100644 app/src/utils/proto.ts rename app/src/{components/resources/shared/helpers.tsx => utils/resources.tsx} (80%) delete mode 100644 pkg/api/datasources/datasource/datasource.go delete mode 100644 pkg/api/datasources/datasource/elasticsearch/elasticsearch.go delete mode 100644 pkg/api/datasources/datasource/prometheus/helpers.go delete mode 100644 pkg/api/datasources/datasource/prometheus/prometheus.go delete mode 100644 pkg/api/datasources/datasource/shared/roundtripper.go delete mode 100644 pkg/api/datasources/datasources.go rename pkg/api/{kubernetes => plugins/application}/apis/application/register.go (100%) rename pkg/api/{kubernetes => plugins/application}/apis/application/v1alpha1/doc.go (100%) rename pkg/api/{kubernetes => plugins/application}/apis/application/v1alpha1/register.go (93%) rename pkg/api/{kubernetes => plugins/application}/apis/application/v1alpha1/types.go (92%) rename pkg/api/{kubernetes => plugins/application}/apis/application/v1alpha1/zz_generated.deepcopy.go (100%) rename pkg/{generated => api/plugins/application}/clientset/versioned/clientset.go (96%) rename pkg/{generated => api/plugins/application}/clientset/versioned/doc.go (100%) rename pkg/{generated => api/plugins/application}/clientset/versioned/fake/clientset_generated.go (88%) rename pkg/{generated => api/plugins/application}/clientset/versioned/fake/doc.go (100%) rename pkg/{generated => api/plugins/application}/clientset/versioned/fake/register.go (95%) rename pkg/{generated => api/plugins/application}/clientset/versioned/scheme/doc.go (100%) rename pkg/{generated => api/plugins/application}/clientset/versioned/scheme/register.go (95%) rename pkg/{generated => api/plugins/application}/clientset/versioned/typed/application/v1alpha1/application.go (97%) rename pkg/{generated => api/plugins/application}/clientset/versioned/typed/application/v1alpha1/application_client.go (93%) rename pkg/{generated => api/plugins/application}/clientset/versioned/typed/application/v1alpha1/doc.go (100%) rename pkg/{generated => api/plugins/application}/clientset/versioned/typed/application/v1alpha1/fake/doc.go (100%) rename pkg/{generated => api/plugins/application}/clientset/versioned/typed/application/v1alpha1/fake/fake_application.go (98%) rename pkg/{generated => api/plugins/application}/clientset/versioned/typed/application/v1alpha1/fake/fake_application_client.go (90%) rename pkg/{generated => api/plugins/application}/clientset/versioned/typed/application/v1alpha1/generated_expansion.go (100%) rename pkg/{generated => api/plugins/application}/informers/externalversions/application/interface.go (86%) rename pkg/{generated => api/plugins/application}/informers/externalversions/application/v1alpha1/application.go (89%) rename pkg/{generated => api/plugins/application}/informers/externalversions/application/v1alpha1/interface.go (92%) rename pkg/{generated => api/plugins/application}/informers/externalversions/factory.go (95%) rename pkg/{generated => api/plugins/application}/informers/externalversions/generic.go (95%) rename pkg/{generated => api/plugins/application}/informers/externalversions/internalinterfaces/factory_interfaces.go (94%) rename pkg/{generated => api/plugins/application}/listers/application/v1alpha1/application.go (97%) rename pkg/{generated => api/plugins/application}/listers/application/v1alpha1/expansion_generated.go (100%) create mode 100644 pkg/api/plugins/application/proto/application.pb.go create mode 100644 pkg/api/plugins/application/proto/application_deepcopy.gen.go rename pkg/api/{ => plugins}/clusters/cluster/cluster.go (55%) rename pkg/api/{ => plugins}/clusters/clusters.go (59%) rename pkg/{generated => api/plugins/clusters}/proto/clusters.pb.go (56%) rename pkg/{generated => api/plugins/clusters}/proto/clusters_deepcopy.gen.go (75%) rename pkg/{generated => api/plugins/clusters}/proto/clusters_grpc.pb.go (87%) rename pkg/api/{ => plugins}/clusters/provider/incluster/incluster.go (86%) rename pkg/api/{ => plugins}/clusters/provider/kubeconfig/kubeconfig.go (94%) rename pkg/api/{ => plugins}/clusters/provider/provider.go (86%) delete mode 100644 pkg/generated/proto/application.pb.go delete mode 100644 pkg/generated/proto/application_deepcopy.gen.go delete mode 100644 pkg/generated/proto/datasources.pb.go delete mode 100644 pkg/generated/proto/datasources_deepcopy.gen.go delete mode 100644 pkg/generated/proto/datasources_grpc.pb.go delete mode 100644 proto/datasources.proto diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bcb5cebf..450f0f2a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -NOTE: As semantic versioning states all 0.y.z releases can contain breaking changes in API (flags, grpc API, any backward compatibility). We use :warning: Breaking change :warning: to mark changes that are not backward compatible (relates only to v0.y.z releases). +NOTE: As semantic versioning states all 0.y.z releases can contain breaking changes in API (flags, grpc API, any backward compatibility). We use :warning: *Breaking change:* :warning: to mark changes that are not backward compatible (relates only to v0.y.z releases). ## Unreleased @@ -23,3 +23,4 @@ NOTE: As semantic versioning states all 0.y.z releases can contain breaking chan ### Changed - [#7](https://github.com/kobsio/kobs/pull/7): Share datasource options between components and allow sharing of URLs. +- [#11](https://github.com/kobsio/kobs/pull/11): :warning: *Breaking change:* :warning: Refactor cluster and application handling. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index be1271813..5c02a42d0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,7 +34,7 @@ The following section explains various suggestions and procedures to note during ### Prerequisites - It is strongly recommended that you use macOS or Linux distributions for development. -- You have Go 1.15.0 or newer installed. +- You have Go 1.16.0 or newer installed. - You have Node.js 14.0.0 or newer installed. - For the React UI, you will need a working NodeJS environment and the Yarn package manager to compile the Web UI assets. diff --git a/Makefile b/Makefile index ace03e2b7..c5b7b37e2 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,8 @@ REPO ?= github.com/kobsio/kobs REVISION ?= $(shell git rev-parse HEAD) VERSION ?= $(shell git describe --tags) +PLUGINS ?= $(shell find ./proto -name '*.proto' | sed -e 's/^.\/proto\///' | sed -e 's/.proto//') + .PHONY: build build: @go build -ldflags "-X ${REPO}/pkg/version.Version=${VERSION} \ @@ -19,28 +21,26 @@ generate: generate-proto generate-crd .PHONY: generate-proto generate-proto: - @protoc --proto_path=proto --go_out=pkg/generated/proto --go_opt=paths=source_relative --go-grpc_out=pkg/generated/proto --go-grpc_opt=paths=source_relative --deepcopy_out=pkg/generated/proto --js_out=import_style=commonjs:app/src/generated/proto --plugin=protoc-gen-ts=app/node_modules/.bin/protoc-gen-ts --ts_out=service=grpc-web:app/src/generated/proto --grpc-web_out=import_style=commonjs,mode=grpcwebtext:app/src/generated/proto proto/clusters.proto - @protoc --proto_path=proto --go_out=pkg/generated/proto --go_opt=paths=source_relative --go-grpc_out=pkg/generated/proto --go-grpc_opt=paths=source_relative --deepcopy_out=pkg/generated/proto --js_out=import_style=commonjs:app/src/generated/proto --plugin=protoc-gen-ts=app/node_modules/.bin/protoc-gen-ts --ts_out=service=grpc-web:app/src/generated/proto --grpc-web_out=import_style=commonjs,mode=grpcwebtext:app/src/generated/proto proto/datasources.proto - @protoc --proto_path=proto --go_out=pkg/generated/proto --go_opt=paths=source_relative --go-grpc_out=pkg/generated/proto --go-grpc_opt=paths=source_relative --deepcopy_out=pkg/generated/proto --js_out=import_style=commonjs:app/src/generated/proto --plugin=protoc-gen-ts=app/node_modules/.bin/protoc-gen-ts --ts_out=service=grpc-web:app/src/generated/proto --grpc-web_out=import_style=commonjs,mode=grpcwebtext:app/src/generated/proto proto/application.proto - @rm -rf ./pkg/generated/proto/clusters_deepcopy.gen.go - @rm -rf ./pkg/generated/proto/datasources_deepcopy.gen.go - @rm -rf ./pkg/generated/proto/application_deepcopy.gen.go - @mv ./pkg/generated/proto/github.com/kobsio/kobs/pkg/generated/proto/clusters_deepcopy.gen.go ./pkg/generated/proto - @mv ./pkg/generated/proto/github.com/kobsio/kobs/pkg/generated/proto/datasources_deepcopy.gen.go ./pkg/generated/proto - @mv ./pkg/generated/proto/github.com/kobsio/kobs/pkg/generated/proto/application_deepcopy.gen.go ./pkg/generated/proto - @rm -rf ./pkg/generated/proto/github.com + for plugin in $(PLUGINS); do \ + mkdir -p pkg/api/plugins/$$plugin/proto; \ + mkdir -p app/src/proto; \ + protoc --proto_path=proto --go_out=pkg/api/plugins/$$plugin/proto --go_opt=paths=source_relative --go-grpc_out=pkg/api/plugins/$$plugin/proto --go-grpc_opt=paths=source_relative --deepcopy_out=pkg/api/plugins/$$plugin/proto --js_out=import_style=commonjs:app/src/proto --plugin=protoc-gen-ts=app/node_modules/.bin/protoc-gen-ts --ts_out=service=grpc-web:app/src/proto --grpc-web_out=import_style=commonjs,mode=grpcwebtext:app/src/proto proto/$$plugin.proto; \ + rm -rf ./pkg/api/plugins/$$plugin/proto/$$plugin\_deepcopy.gen.go; \ + mv ./pkg/api/plugins/$$plugin/proto/github.com/kobsio/kobs/pkg/api/plugins/$$plugin/proto/$$plugin\_deepcopy.gen.go ./pkg/api/plugins/$$plugin/proto; \ + rm -rf ./pkg/api/plugins/$$plugin/proto/github.com; \ + done .PHONY: generate-crd generate-crd: - @${GOPATH}/src/k8s.io/code-generator/generate-groups.sh "deepcopy,client,informer,lister" github.com/kobsio/kobs/pkg/generated github.com/kobsio/kobs/pkg/api/kubernetes/apis application:v1alpha1 --output-base ./tmp - @rm -rf ./pkg/api/kubernetes/apis/application/v1alpha1/zz_generated.deepcopy.go - @rm -rf ./pkg/generated/clientset - @rm -rf ./pkg/generated/informers - @rm -rf ./pkg/generated/listers - @mv ./tmp/github.com/kobsio/kobs/pkg/api/kubernetes/apis/application/v1alpha1/zz_generated.deepcopy.go ./pkg/api/kubernetes/apis/application/v1alpha1 - @mv ./tmp/github.com/kobsio/kobs/pkg/generated/clientset ./pkg/generated/clientset - @mv ./tmp/github.com/kobsio/kobs/pkg/generated/informers ./pkg/generated/informers - @mv ./tmp/github.com/kobsio/kobs/pkg/generated/listers ./pkg/generated/listers + @${GOPATH}/src/k8s.io/code-generator/generate-groups.sh "deepcopy,client,informer,lister" github.com/kobsio/kobs/pkg/api/plugins/application github.com/kobsio/kobs/pkg/api/plugins/application/apis application:v1alpha1 --output-base ./tmp + @rm -rf ./pkg/api/plugins/application/apis/application/v1alpha1/zz_generated.deepcopy.go + @rm -rf ./pkg/api/plugins/application/clientset + @rm -rf ./pkg/api/plugins/application/informers + @rm -rf ./pkg/api/plugins/application/listers + @mv ./tmp/github.com/kobsio/kobs/pkg/api/plugins/application/apis/application/v1alpha1/zz_generated.deepcopy.go ./pkg/api/plugins/application/apis/application/v1alpha1 + @mv ./tmp/github.com/kobsio/kobs/pkg/api/plugins/application/clientset ./pkg/api/plugins/application/clientset + @mv ./tmp/github.com/kobsio/kobs/pkg/api/plugins/application/informers ./pkg/api/plugins/application/informers + @mv ./tmp/github.com/kobsio/kobs/pkg/api/plugins/application/listers ./pkg/api/plugins/application/listers @rm -rf ./tmp -controller-gen "crd:trivialVersions=true" paths="./..." output:crd:artifacts:config=deploy/kustomize/crds diff --git a/app/.eslintignore b/app/.eslintignore index c83f90a2b..4c3c29bf7 100644 --- a/app/.eslintignore +++ b/app/.eslintignore @@ -1 +1 @@ -src/generated +src/proto diff --git a/app/package.json b/app/package.json index 5e1c51df7..0959d7677 100644 --- a/app/package.json +++ b/app/package.json @@ -146,16 +146,16 @@ "@patternfly/react-core": "^4.90.2", "@patternfly/react-table": "^4.20.15", "@types/google-protobuf": "^3.7.4", - "@types/highlight.js": "^10.1.0", "@types/node": "^12.0.0", "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", "@types/react-router-dom": "^5.1.7", "google-protobuf": "^3.15.0-rc.1", "grpc-web": "^1.2.1", - "highlight.js": "^10.6.0", "js-yaml": "^4.0.0", + "jsonpath-plus": "^5.0.4", "react": "^17.0.1", + "react-ace": "^9.3.0", "react-dom": "^17.0.1", "react-router-dom": "^5.2.0", "react-scripts": "4.0.2", diff --git a/app/public/img/datasources/jaeger.png b/app/public/img/datasources/jaeger.png new file mode 100644 index 0000000000000000000000000000000000000000..a4a6d23b6c278be3d6a59f2b5e43f6837ed3c5e2 GIT binary patch literal 92280 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 ZKFX|G=95n-@bp#&22WQ%mvv4FO#t| { - const Header = } />; + const Header = ( + } /> + ); return ( - - - - - - - - - - - - + + + + + + + + + + + + ); }; diff --git a/app/src/app.css b/app/src/app.css index ba50b76b8..f9345dbe2 100644 --- a/app/src/app.css +++ b/app/src/app.css @@ -4,121 +4,3 @@ #root { height: 100%; } - -/* kobs-home-item - * The items on the home page are clickable, so we have to set the correct cursor style */ -.kobs-home-item { - cursor: pointer; -} - -/* kobs-drawer-pagesection - * When we use a PageSection component within a Drawer component, we have to explicit set the height of the PageSection - * to 100%. */ -.kobs-drawer-pagesection { - min-height: 100%; -} - -/* kobs-drawer-actions - * Customize the drawer actions. For example we remove the padding from the action buttons, so that they are displayed - * in one line with the drawer title. */ -.kobs-drawer-actions button { - padding: 0; -} - -/* kobs-drawer-panel-body - * Is used to modify the style of the drawer panel body. For example we have to set the maximum width for tabs used - * within the drawer and apply some margin for the top and bottom of the tabs content. */ -.kobs-drawer-panel-body .pf-c-tab-content { - margin: 16px 0px; - max-width: 100%; - overflow-x: scroll; -} - -/* kobsio-pagesection-toolbar - * Is used to display the toolbar within a page section. For that we have to adjust the z-index, so that the toolbar is - * always displayed above the other components. We also remove the bottom, because the padding is already applied by the - * PageSection component. - * We also remove the padding for the ToolbarContent component, so that the ToolbarItems are aligned with the other - * content in the PageSection. */ -.kobsio-pagesection-toolbar { - padding-bottom: 0px; - z-index: 300; -} - -.kobsio-pagesection-toolbar .pf-c-toolbar__content { - padding: 0px; -} - -/* kobsio-pagesection-tabs - * This is used if we display tabs at the bottom of a PageSection component, to remove the padding. This allows us to - * align the tabs line with the end of the PageSection. */ -.kobsio-pagesection-tabs { - padding-bottom: 0px; -} - -/* kobs-fullwidth - * We often need to modify the width of elements, so that they are taken the full width of the parent. For example to - * display an item in the toolbar, which is aligned on the right side. For that the following class can be used. */ -.kobs-fullwidth { - width: 100%; -} - -/* kobsio-options-list - * Do not center the items vertically and apply some padding between the list items for smaller screens. */ -.kobsio-options-list { - align-items: flex-start; -} - -.kobsio-options-list-item { - padding-bottom: 16px; -} - -/* kobsio-charts-grid - * Apply some marging on the top of the charts grid. */ -.kobsio-charts-grid { - margin-top: 16px; -} - -/* kobsio-chart-container - * Set the width and height for a chart. The width and height is applied to the chart container and then used via - * useRef within the chart. */ -.kobsio-chart-container-default { - width: 100%; - height: 300px; -} - -.kobsio-chart-container-default-small { - width: 100%; - height: 200px; -} - -.kobsio-chart-container-sparkline { - width: 100%; - height: 150px; - position: relative; -} - -.kobsio-chart-container-sparkline.small { - height: 75px; -} - -.kobsio-chart-container-sparkline-value { - width: 100%; - top: 63px; - font-size: 24px; - position: absolute; - text-align: center -} - -/* kobsis-table-wrapper - * Wrap a table component, so it looks nice within a page, but allow scrolling so the user can see all the data. */ -.kobsis-table-wrapper { - max-width: 100%; - overflow-x: scroll; -} - -/* kobsio-tab-content - * Set a min height of 100% for the tab content. */ -.kobsio-tab-content { - min-height: 100%; -} diff --git a/app/src/components/Editor.tsx b/app/src/components/Editor.tsx new file mode 100644 index 000000000..e4f6b2bb6 --- /dev/null +++ b/app/src/components/Editor.tsx @@ -0,0 +1,48 @@ +import React, { useRef } from 'react'; +import AceEditor from 'react-ace'; + +import 'ace-builds/src-noconflict/mode-yaml'; +import 'ace-builds/src-noconflict/theme-nord_dark'; + +// IEditorProps is the interface for the Editor props. The editor requires a value, which is shown in the Editor field, +// a mode, which defines the language. If the editor isn't set to read only the user can also pass in a onChange +// function to retrieve the changes from the editor. +interface IEditorProps { + value: string; + mode: string; + readOnly: boolean; + onChange?: (newValue: string) => void; +} + +// Editor is the editor component, which can be used to show for example the yaml representation of a resource. +const Editor: React.FunctionComponent = ({ value, mode, readOnly, onChange }: IEditorProps) => { + const editor = useRef(null); + + const changeValue = (newValue: string): void => { + if (onChange) { + onChange(newValue); + } + }; + + return ( + + ); +}; + +export default Editor; diff --git a/app/src/components/Home.tsx b/app/src/components/Home.tsx new file mode 100644 index 000000000..611ac6176 --- /dev/null +++ b/app/src/components/Home.tsx @@ -0,0 +1,26 @@ +import { Gallery, GalleryItem, PageSection, PageSectionVariants } from '@patternfly/react-core'; +import React from 'react'; + +import { applicationsDescription, resourcesDescription } from 'utils/constants'; +import HomeItem from 'components/HomeItem'; + +// Home is the root component of kobs. It is used to render a list of pages, which can be used by the user. The items +// which are displayed to the user are the applications and resources page and a list of all configured plugins. +// The items for the gallery should always use the HomeItem component, this will render a card, which are selectable. By +// a click on the item the user is navigated to the corresponding page. +const Home: React.FunctionComponent = () => { + return ( + + + + + + + + + + + ); +}; + +export default Home; diff --git a/app/src/components/HomeItem.tsx b/app/src/components/HomeItem.tsx new file mode 100644 index 000000000..d5b93d587 --- /dev/null +++ b/app/src/components/HomeItem.tsx @@ -0,0 +1,29 @@ +import { Card, CardBody, CardTitle } from '@patternfly/react-core'; +import React from 'react'; +import { useHistory } from 'react-router-dom'; + +// IHomeItemProps is the interface for an item on the home page. Each item contains a title, body and a link. +interface IHomeItemProps { + body: string; + link: string; + title: string; +} + +// HomeItem is used to render an item in the home page. It requires a title, body and a link. When the card is clicked, +// the user is redirected to the provided link. +const HomeItem: React.FunctionComponent = ({ body, link, title }: IHomeItemProps) => { + const history = useHistory(); + + const handleClick = (): void => { + history.push(link); + }; + + return ( + + {title} + {body} + + ); +}; + +export default HomeItem; diff --git a/app/src/components/shared/Title.tsx b/app/src/components/Title.tsx similarity index 73% rename from app/src/components/shared/Title.tsx rename to app/src/components/Title.tsx index e49a86df9..b98d1f6b9 100644 --- a/app/src/components/shared/Title.tsx +++ b/app/src/components/Title.tsx @@ -1,7 +1,10 @@ import React from 'react'; +// TTitleSize are is the size type. We support the lg and xl property of Patternfly for the text size of the title. type TTitleSize = 'lg' | 'xl'; +// ITitleProps are the properties for the Title component. The user must provide the title, subtitle and the size for +// the title. interface ITitleProps { title: string; subtitle: string; diff --git a/app/src/components/applications/Application.tsx b/app/src/components/applications/Application.tsx index d381c1cd0..bd9f5100e 100644 --- a/app/src/components/applications/Application.tsx +++ b/app/src/components/applications/Application.tsx @@ -7,62 +7,57 @@ import { ListVariant, PageSection, PageSectionVariants, + Spinner, } from '@patternfly/react-core'; -import { Link, useHistory, useParams } from 'react-router-dom'; import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useHistory, useParams } from 'react-router-dom'; -import { GetApplicationRequest, GetApplicationResponse } from 'generated/proto/clusters_pb'; -import Tabs, { DEFAULT_TAB } from 'components/applications/details/Tabs'; -import { Application } from 'generated/proto/application_pb'; -import { ClustersPromiseClient } from 'generated/proto/clusters_grpc_web_pb'; -import TabsContent from 'components/applications/details/TabsContent'; -import Title from 'components/shared/Title'; +import { ClustersPromiseClient, GetApplicationRequest, GetApplicationResponse } from 'proto/clusters_grpc_web_pb'; +import ApplicationTabs from 'components/applications/ApplicationTabs'; +import ApplicationTabsContent from 'components/applications/ApplicationTabsContent'; +import { Application as IApplication } from 'proto/application_pb'; +import Title from 'components/Title'; import { apiURL } from 'utils/constants'; -const clustersService = new ClustersPromiseClient(apiURL, null, null); +interface IDataState { + application?: IApplication.AsObject; + error: string; + isLoading: boolean; +} interface IApplicationsParams { cluster: string; namespace: string; name: string; } +// clustersService is the Clusters gRPC service, which is used to get an application. +const clustersService = new ClustersPromiseClient(apiURL, null, null); -// Applications is the page component to show a single application. The application is determined by the provided page -// params. When the application could not be loaded an error is shown. If the application was successfully loaded, we -// show the same components as in the tabs from the Applications drawer. -const Applications: React.FunctionComponent = () => { +const Application: React.FunctionComponent = () => { const history = useHistory(); const params = useParams(); - const [application, setApplication] = useState(undefined); - const [error, setError] = useState(''); + const [data, setData] = useState({ application: undefined, error: '', isLoading: true }); - const [tab, setTab] = useState(DEFAULT_TAB); + const [activeTab, setActiveTab] = useState('resources'); const refResourcesContent = useRef(null); - const refMetricsContent = useRef(null); - const refLogsContent = useRef(null); - const goToOverview = (): void => { - history.push('/'); - }; - - // fetchApplication is used to fetch the application from the gRPC API. This is done every time the page paramertes - // change. When there is an error during the fetch, the user will see the error. const fetchApplication = useCallback(async () => { try { + setData({ application: undefined, error: '', isLoading: true }); + const getApplicationRequest = new GetApplicationRequest(); getApplicationRequest.setCluster(params.cluster); getApplicationRequest.setNamespace(params.namespace); getApplicationRequest.setName(params.name); - const getApplicationsResponse: GetApplicationResponse = await clustersService.getApplication( + const getApplicationResponse: GetApplicationResponse = await clustersService.getApplication( getApplicationRequest, null, ); - setError(''); - setApplication(getApplicationsResponse.getApplication()); + setData({ application: getApplicationResponse.toObject().application, error: '', isLoading: false }); } catch (err) { - setError(err.message); + setData({ application: undefined, error: err.message, isLoading: false }); } }, [params.cluster, params.namespace, params.name]); @@ -70,69 +65,61 @@ const Applications: React.FunctionComponent = () => { fetchApplication(); }, [fetchApplication]); - if (!application) { - return null; + if (data.isLoading) { + return ; } - // If there is an error, we will show it to the user. The user then has the option to retry the failed API call or to - // go to the overview page. - if (error) { + if (data.error || !data.application) { return ( - - - Retry - Overview - - } - > -

{error}

-
-
+ + history.push('/')}>Home + Retry + + } + > +

{data.error ? data.error : 'Application is undefined'}

+
); } return ( - + - <List variant={ListVariant.inline}> - {application.getLinksList().map((link, index) => ( - <ListItem key={index}> - <Link target="_blank" to={link.getLink}> - {link.getTitle()} - </Link> - </ListItem> - ))} - </List> - <Tabs - tab={tab} - setTab={(t: string): void => setTab(t)} - refResourcesContent={refResourcesContent} - refMetricsContent={refMetricsContent} - refLogsContent={refLogsContent} - /> + {data.application.details ? ( + <div> + <p>{data.application.details.description}</p> + <List variant={ListVariant.inline}> + {data.application.details.linksList.map((link, index) => ( + <ListItem key={index}> + <a href={link.link} rel="noreferrer" target="_blank"> + {link.title} + </a> + </ListItem> + ))} + </List> + </div> + ) : null} + <ApplicationTabs activeTab={activeTab} setTab={setActiveTab} refResourcesContent={refResourcesContent} /> </PageSection> - <PageSection variant={PageSectionVariants.default}> - <TabsContent - application={application} - tab={tab} - refResourcesContent={refResourcesContent} - refMetricsContent={refMetricsContent} - refLogsContent={refLogsContent} - /> - </PageSection> + <ApplicationTabsContent + application={data.application} + activeTab={activeTab} + isInDrawer={false} + refResourcesContent={refResourcesContent} + /> </React.Fragment> ); }; -export default Applications; +export default Application; diff --git a/app/src/components/applications/ApplicationDetails.tsx b/app/src/components/applications/ApplicationDetails.tsx new file mode 100644 index 000000000..1f8063b79 --- /dev/null +++ b/app/src/components/applications/ApplicationDetails.tsx @@ -0,0 +1,72 @@ +import { + DrawerActions, + DrawerCloseButton, + DrawerHead, + DrawerPanelBody, + DrawerPanelContent, + List, + ListItem, + ListVariant, +} from '@patternfly/react-core'; +import React, { useRef, useState } from 'react'; + +import { Application } from 'proto/application_pb'; +import ApplicationDetailsLink from 'components/applications/ApplicationDetailsLink'; +import ApplicationTabs from 'components/applications/ApplicationTabs'; +import ApplicationTabsContent from 'components/applications/ApplicationTabsContent'; +import Title from 'components/Title'; + +interface IApplicationDetailsProps { + application: Application.AsObject; + close: () => void; +} + +// ApplicationDetails is the details view of an application, which is displayed as a drawer panel. +const ApplicationDetails: React.FunctionComponent<IApplicationDetailsProps> = ({ + application, + close, +}: IApplicationDetailsProps) => { + const [activeTab, setActiveTab] = useState<string>('resources'); + const refResourcesContent = useRef<HTMLElement>(null); + + return ( + <DrawerPanelContent minSize="50%"> + <DrawerHead> + <Title title={application.name} subtitle={`${application.namespace} (${application.cluster})`} size="lg" /> + <DrawerActions style={{ padding: 0 }}> + <DrawerCloseButton onClose={close} /> + </DrawerActions> + </DrawerHead> + + <DrawerPanelBody> + {application.details ? ( + <div> + <p>{application.details.description}</p> + + <List variant={ListVariant.inline}> + <ListItem> + <ApplicationDetailsLink application={application} /> + </ListItem> + {application.details.linksList.map((link, index) => ( + <ListItem key={index}> + <a href={link.link} rel="noreferrer" target="_blank"> + {link.title} + </a> + </ListItem> + ))} + </List> + </div> + ) : null} + <ApplicationTabs activeTab={activeTab} setTab={setActiveTab} refResourcesContent={refResourcesContent} /> + <ApplicationTabsContent + application={application} + activeTab={activeTab} + isInDrawer={true} + refResourcesContent={refResourcesContent} + /> + </DrawerPanelBody> + </DrawerPanelContent> + ); +}; + +export default ApplicationDetails; diff --git a/app/src/components/applications/ApplicationDetailsLink.tsx b/app/src/components/applications/ApplicationDetailsLink.tsx new file mode 100644 index 000000000..6a2c66858 --- /dev/null +++ b/app/src/components/applications/ApplicationDetailsLink.tsx @@ -0,0 +1,29 @@ +import { Link, useLocation } from 'react-router-dom'; +import React, { useEffect, useState } from 'react'; + +import { Application } from 'proto/application_pb'; + +interface IApplicationDetailsLinkProps { + application: Application.AsObject; +} + +// ApplicationDetailsLink renders the link to the details page for an application inside the DrawerPanel of the +// applications page. Everytime when the location.search parameter (query parameters) are changing, we are adding the +// new parameters to the link, so that for example a change of the selected time range is also used in the details page. +const ApplicationDetailsLink: React.FunctionComponent<IApplicationDetailsLinkProps> = ({ + application, +}: IApplicationDetailsLinkProps) => { + const location = useLocation(); + + const [link, setLink] = useState<string>( + `/applications/${application.cluster}/${application.namespace}/${application.name}`, + ); + + useEffect(() => { + setLink(`/applications/${application.cluster}/${application.namespace}/${application.name}${location.search}`); + }, [application, location.search]); + + return <Link to={link}>Details</Link>; +}; + +export default ApplicationDetailsLink; diff --git a/app/src/components/applications/ApplicationGallery.tsx b/app/src/components/applications/ApplicationGallery.tsx new file mode 100644 index 000000000..732dea833 --- /dev/null +++ b/app/src/components/applications/ApplicationGallery.tsx @@ -0,0 +1,28 @@ +import { Gallery, GalleryItem } from '@patternfly/react-core'; +import React from 'react'; + +import { Application } from 'proto/application_pb'; +import ApplicationItem from 'components/applications/ApplicationItem'; + +interface IApplicationGalleryProps { + applications: Application.AsObject[]; + selectApplication: (application: Application.AsObject) => void; +} + +// ApplicationGallery is the component to display all applications inside a gallery view. +const ApplicationGallery: React.FunctionComponent<IApplicationGalleryProps> = ({ + applications, + selectApplication, +}: IApplicationGalleryProps) => { + return ( + <Gallery hasGutter={true}> + {applications.map((application, index) => ( + <GalleryItem key={index}> + <ApplicationItem application={application} selectApplication={selectApplication} /> + </GalleryItem> + ))} + </Gallery> + ); +}; + +export default ApplicationGallery; diff --git a/app/src/components/applications/ApplicationItem.tsx b/app/src/components/applications/ApplicationItem.tsx new file mode 100644 index 000000000..4d6eeeaee --- /dev/null +++ b/app/src/components/applications/ApplicationItem.tsx @@ -0,0 +1,28 @@ +import { Card, CardBody, CardTitle } from '@patternfly/react-core'; +import React from 'react'; + +import { Application } from 'proto/application_pb'; + +interface IApplicationItemProps { + application: Application.AsObject; + selectApplication: (application: Application.AsObject) => void; +} + +// ApplicationItem renders a single application in a Card component. With the title of the application and the +// description of the application. If the user doesn't provide a description, we just show the namespace and cluster of +// the application in the card body. +const ApplicationItem: React.FunctionComponent<IApplicationItemProps> = ({ + application, + selectApplication, +}: IApplicationItemProps) => { + return ( + <Card style={{ cursor: 'pointer' }} isHoverable={true} onClick={(): void => selectApplication(application)}> + <CardTitle>{application.name}</CardTitle> + <CardBody> + {application.details ? application.details.description : `${application.namespace} (${application.cluster})`} + </CardBody> + </Card> + ); +}; + +export default ApplicationItem; diff --git a/app/src/components/applications/ApplicationTabs.tsx b/app/src/components/applications/ApplicationTabs.tsx new file mode 100644 index 000000000..6382c831d --- /dev/null +++ b/app/src/components/applications/ApplicationTabs.tsx @@ -0,0 +1,34 @@ +import { Tab, TabTitleText, Tabs } from '@patternfly/react-core'; +import React from 'react'; + +interface IApplicationTabsParams { + activeTab: string; + setTab(tab: string): void; + refResourcesContent: React.RefObject<HTMLElement>; +} + +// ApplicationTabs is the component to render all tabs for an application. An application always contains a tab for +// resources and a dynamic list of plugins. +const ApplicationTabs: React.FunctionComponent<IApplicationTabsParams> = ({ + activeTab, + setTab, + refResourcesContent, +}: IApplicationTabsParams) => { + return ( + <Tabs + className="pf-u-mt-md" + isFilled={true} + activeKey={activeTab} + onSelect={(event, tabIndex): void => setTab(tabIndex.toString())} + > + <Tab + eventKey="resources" + title={<TabTitleText>Resources</TabTitleText>} + tabContentId="refResources" + tabContentRef={refResourcesContent} + /> + </Tabs> + ); +}; + +export default ApplicationTabs; diff --git a/app/src/components/applications/ApplicationTabsContent.tsx b/app/src/components/applications/ApplicationTabsContent.tsx new file mode 100644 index 000000000..3267ceee0 --- /dev/null +++ b/app/src/components/applications/ApplicationTabsContent.tsx @@ -0,0 +1,71 @@ +import { + Drawer, + DrawerContent, + DrawerContentBody, + PageSection, + PageSectionVariants, + TabContent, +} from '@patternfly/react-core'; +import React, { useState } from 'react'; +import { IRow } from '@patternfly/react-table'; + +import { Application } from 'proto/application_pb'; +import ResourceDetails from 'components/resources/ResourceDetails'; +import ResourcesList from 'components/resources/ResourcesList'; + +interface IApplicationTabsContent { + application: Application.AsObject; + activeTab: string; + isInDrawer: boolean; + refResourcesContent: React.RefObject<HTMLElement>; +} + +// ApplicationTabsContent renders the content of an tab. If the component isn't rendered inside a drawer it provides a +// drawer, so the a component in the tab content can display some details in his own drawer. +const ApplicationTabsContent: React.FunctionComponent<IApplicationTabsContent> = ({ + application, + activeTab, + isInDrawer, + refResourcesContent, +}: IApplicationTabsContent) => { + const [panelContent, setPanelContent] = useState<React.ReactNode | undefined>(undefined); + + return ( + <Drawer isExpanded={panelContent !== undefined}> + <DrawerContent panelContent={panelContent}> + <DrawerContentBody> + <PageSection + style={isInDrawer ? { minHeight: '100%', paddingLeft: '0px', paddingRight: '0px' } : { minHeight: '100%' }} + variant={isInDrawer ? PageSectionVariants.light : PageSectionVariants.default} + > + <TabContent + style={{ minHeight: '100%' }} + eventKey="resources" + id="refResources" + activeKey={activeTab} + ref={refResourcesContent} + aria-label="Resources" + > + <ResourcesList + resources={{ + clusters: [application.cluster], + namespaces: [application.namespace], + resources: application.resourcesList, + }} + selectResource={(resource: IRow): void => + isInDrawer + ? setPanelContent(undefined) + : setPanelContent( + <ResourceDetails resource={resource} close={(): void => setPanelContent(undefined)} />, + ) + } + /> + </TabContent> + </PageSection> + </DrawerContentBody> + </DrawerContent> + </Drawer> + ); +}; + +export default ApplicationTabsContent; diff --git a/app/src/components/applications/Applications.tsx b/app/src/components/applications/Applications.tsx index adf2420b3..ce107fea4 100644 --- a/app/src/components/applications/Applications.tsx +++ b/app/src/components/applications/Applications.tsx @@ -4,68 +4,63 @@ import { Drawer, DrawerContent, DrawerContentBody, - Gallery, - GalleryItem, PageSection, PageSectionVariants, Title, } from '@patternfly/react-core'; -import React, { useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; -import { GetApplicationsRequest, GetApplicationsResponse } from 'generated/proto/clusters_pb'; -import { apiURL, applicationsDescription } from 'utils/constants'; -import { Application } from 'generated/proto/application_pb'; -import Card from 'components/applications/overview/Card'; -import { ClustersPromiseClient } from 'generated/proto/clusters_grpc_web_pb'; -import DrawerPanel from 'components/applications/details/DrawerPanel'; -import Filter from 'components/resources/shared/Filter'; +import { ClustersPromiseClient, GetApplicationsRequest, GetApplicationsResponse } from 'proto/clusters_grpc_web_pb'; +import { Application } from 'proto/application_pb'; +import ApplicationDetails from 'components/applications/ApplicationDetails'; +import ApplicationGallery from 'components/applications/ApplicationGallery'; +import ApplicationsToolbar from 'components/applications/ApplicationsToolbar'; +import { apiURL } from 'utils/constants'; +import { applicationsDescription } from 'utils/constants'; +// clustersService is the Clusters gRPC service, which is used to get a list of resources. const clustersService = new ClustersPromiseClient(apiURL, null, null); -// Applications displays a list of applications (defined via the Application CRD). The applications can be filtered by -// cluster and namespace. -// When a application is selected it is shown in a drawer with some additional details, like resources, metrics, logs -// and traces. +export interface IScope { + clusters: string[]; + namespaces: string[]; +} + +// Applications is the page to display a list of selected applications. To get the applications the user can select a +// scope (list of clusters and namespaces). const Applications: React.FunctionComponent = () => { - const [applications, setApplications] = useState<Application[]>([]); - const [selectedApplication, setSelectedApplication] = useState<Application | undefined>(undefined); + const [scope, setScope] = useState<IScope | undefined>(undefined); + const [applications, setApplications] = useState<Application.AsObject[]>([]); + const [selectedApplication, setSelectedApplication] = useState<Application.AsObject | undefined>(undefined); const [error, setError] = useState<string>(''); - const [isLoading, setIsLoading] = useState<boolean>(false); - - // fetchApplications is the function to fetch all applications for a list of clusters and namespaces from the gRPC - // API. The function is used via the onFilter property of the Filter component. - const fetchApplications = async (clusters: string[], namespaces: string[]): Promise<void> => { - try { - if (clusters.length === 0 || namespaces.length === 0) { - throw new Error('You must select a cluster and a namespace'); - } else { - setIsLoading(true); + // fetchApplications is used to fetch a list of applications. To get the list of applications the user has to select + // a list of clusters and namespaces. + const fetchApplications = useCallback(async () => { + if (scope && scope.clusters.length > 0 && scope.namespaces.length > 0) { + try { const getApplicationsRequest = new GetApplicationsRequest(); - getApplicationsRequest.setClustersList(clusters); - getApplicationsRequest.setNamespacesList(namespaces); + getApplicationsRequest.setClustersList(scope.clusters); + getApplicationsRequest.setNamespacesList(scope.namespaces); const getApplicationsResponse: GetApplicationsResponse = await clustersService.getApplications( getApplicationsRequest, null, ); - const tmpApplications = getApplicationsResponse.getApplicationsList(); - - if (tmpApplications.length > 0) { - setError(''); - setApplications(tmpApplications); - } else { - setError('No applications were found, adjust the cluster and namespace filter.'); - } - - setIsLoading(false); + setApplications(getApplicationsResponse.toObject().applicationsList); + setError(''); + } catch (err) { + setError(err.message); } - } catch (err) { - setError(err.message); - setIsLoading(false); } - }; + }, [scope]); + + // useEffect is used to call the fetchApplications function every time the list of clusters and namespaces (scope), + // changes. + useEffect(() => { + fetchApplications(); + }, [fetchApplications]); return ( <React.Fragment> @@ -74,31 +69,39 @@ const Applications: React.FunctionComponent = () => { Applications

{applicationsDescription}

- +
setSelectedApplication(undefined)} /> + setSelectedApplication(undefined)} + /> ) : undefined } > - - {error ? ( - + + {!scope ? ( + +

Select a list of clusters and namespaces from the toolbar.

+
+ ) : scope.clusters.length === 0 || scope.namespaces.length === 0 ? ( + +

+ You have to select a minimum of one cluster and namespace from the toolbar to search for + applications. +

+
+ ) : error ? ( +

{error}

) : ( - - {applications.map((application, index) => ( - - - - ))} - + )}
diff --git a/app/src/components/applications/ApplicationsToolbar.tsx b/app/src/components/applications/ApplicationsToolbar.tsx new file mode 100644 index 000000000..22e2873df --- /dev/null +++ b/app/src/components/applications/ApplicationsToolbar.tsx @@ -0,0 +1,100 @@ +import { + Button, + ButtonVariant, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, + ToolbarToggleGroup, +} from '@patternfly/react-core'; +import React, { useContext, useState } from 'react'; +import FilterIcon from '@patternfly/react-icons/dist/js/icons/filter-icon'; +import SearchIcon from '@patternfly/react-icons/dist/js/icons/search-icon'; + +import { ClustersContext, IClusterContext } from 'context/ClustersContext'; +import { IScope } from 'components/applications/Applications'; +import ToolbarItemClusters from 'components/resources/ToolbarItemClusters'; +import ToolbarItemNamespaces from 'components/resources/ToolbarItemNamespaces'; + +interface IApplicationsToolbarProps { + setScope: (scope: IScope) => void; +} + +// ApplicationsToolbar is the toolbar, where the user can select a list of clusters and namespaces. When the user clicks +// the search button the setScope function is called with the list of selected clusters and namespaces. +const ApplicationsToolbar: React.FunctionComponent = ({ + setScope, +}: IApplicationsToolbarProps) => { + const clustersContext = useContext(ClustersContext); + const [selectedClusters, setSelectedClusters] = useState([clustersContext.clusters[0]]); + const [selectedNamespaces, setSelectedNamespaces] = useState([]); + + // selectCluster adds/removes the given cluster to the list of selected clusters. When the cluster value is an empty + // string the selected clusters list is cleared. + const selectCluster = (cluster: string): void => { + if (cluster === '') { + setSelectedClusters([]); + } else { + if (selectedClusters.includes(cluster)) { + setSelectedClusters(selectedClusters.filter((item) => item !== cluster)); + } else { + setSelectedClusters([...selectedClusters, cluster]); + } + } + }; + + // selectNamespace adds/removes the given namespace to the list of selected namespaces. When the namespace value is an + // empty string the selected namespaces list is cleared. + const selectNamespace = (namespace: string): void => { + if (namespace === '') { + setSelectedNamespaces([]); + } else { + if (selectedNamespaces.includes(namespace)) { + setSelectedNamespaces(selectedNamespaces.filter((item) => item !== namespace)); + } else { + setSelectedNamespaces([...selectedNamespaces, namespace]); + } + } + }; + + return ( + + + } breakpoint="lg"> + + + + + + + + + + + + + + + ); +}; + +export default ApplicationsToolbar; diff --git a/app/src/components/applications/details/DetailsLink.tsx b/app/src/components/applications/details/DetailsLink.tsx deleted file mode 100644 index eeac7fae0..000000000 --- a/app/src/components/applications/details/DetailsLink.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Link, useLocation } from 'react-router-dom'; -import React, { useEffect, useState } from 'react'; - -import { Application } from 'generated/proto/application_pb'; - -interface IDetailsLinkProps { - application: Application; -} - -// DetailsLink renders the link to the details page for an application inside the DrawerPanel of the applications page. -// Everytime when the location.search parameter (query parameters) are changing, we are adding the new parameters to the -// link, so that for example a change of the selected time range is also used in the details page. -const DetailsLink: React.FunctionComponent = ({ application }: IDetailsLinkProps) => { - const location = useLocation(); - - const [link, setLink] = useState( - `/applications/${application.getCluster()}/${application.getNamespace()}/${application.getName()}`, - ); - - useEffect(() => { - setLink( - `/applications/${application.getCluster()}/${application.getNamespace()}/${application.getName()}${ - location.search - }`, - ); - }, [application, location.search]); - - return Details; -}; - -export default DetailsLink; diff --git a/app/src/components/applications/details/DrawerPanel.tsx b/app/src/components/applications/details/DrawerPanel.tsx deleted file mode 100644 index 1981ed246..000000000 --- a/app/src/components/applications/details/DrawerPanel.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { - DrawerActions, - DrawerCloseButton, - DrawerHead, - DrawerPanelBody, - DrawerPanelContent, - List, - ListItem, - ListVariant, -} from '@patternfly/react-core'; -import React, { useRef, useState } from 'react'; -import { Link } from 'react-router-dom'; - -import Tabs, { DEFAULT_TAB } from 'components/applications/details/Tabs'; -import { Application } from 'generated/proto/application_pb'; -import DetailsLink from 'components/applications/details/DetailsLink'; -import TabsContent from 'components/applications/details/TabsContent'; -import Title from 'components/shared/Title'; - -interface IDrawerPanelProps { - application: Application; - close: () => void; -} - -// DrawerPanel is the drawer panel for an application. It is used to display application details in the applications -// page. The details contains information for resources, metrics, logs and traces. -const DrawerPanel: React.FunctionComponent = ({ application, close }: IDrawerPanelProps) => { - const [tab, setTab] = useState(DEFAULT_TAB); - const refResourcesContent = useRef(null); - const refMetricsContent = useRef(null); - const refLogsContent = useRef(null); - - return ( - - - - <DrawerActions className="kobs-drawer-actions"> - <DrawerCloseButton onClose={close} /> - </DrawerActions> - </DrawerHead> - - <DrawerPanelBody className="kobs-drawer-panel-body"> - <List variant={ListVariant.inline}> - <ListItem> - <DetailsLink application={application} /> - </ListItem> - {application.getLinksList().map((link, index) => ( - <ListItem key={index}> - <Link target="_blank" to={link.getLink}> - {link.getTitle()} - </Link> - </ListItem> - ))} - </List> - - <Tabs - tab={tab} - setTab={(t: string): void => setTab(t)} - refResourcesContent={refResourcesContent} - refMetricsContent={refMetricsContent} - refLogsContent={refLogsContent} - /> - - <TabsContent - application={application} - tab={tab} - refResourcesContent={refResourcesContent} - refMetricsContent={refMetricsContent} - refLogsContent={refLogsContent} - /> - </DrawerPanelBody> - </DrawerPanelContent> - ); -}; - -export default DrawerPanel; diff --git a/app/src/components/applications/details/NotDefined.tsx b/app/src/components/applications/details/NotDefined.tsx deleted file mode 100644 index 6e6506695..000000000 --- a/app/src/components/applications/details/NotDefined.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Button, EmptyState, EmptyStateBody, EmptyStateIcon, Title } from '@patternfly/react-core'; -import React from 'react'; - -interface INotDefinedProps { - title: string; - description: string; - documentation: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - icon: React.ComponentType<any>; -} - -// NotDefined is the component, which is displayed when a component of an application isn't defined. It contains a -// title, description, icon and a link to the corresponding documentation. -const NotDefined: React.FunctionComponent<INotDefinedProps> = ({ - title, - description, - icon, - documentation, -}: INotDefinedProps) => { - return ( - <EmptyState> - <EmptyStateIcon icon={icon} /> - <Title headingLevel="h4" size="lg"> - {title} - - {description} - - - ); -}; - -export default NotDefined; diff --git a/app/src/components/applications/details/Tabs.tsx b/app/src/components/applications/details/Tabs.tsx deleted file mode 100644 index 89f28830c..000000000 --- a/app/src/components/applications/details/Tabs.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Tabs as PatternflyTabs, Tab, TabTitleText } from '@patternfly/react-core'; -import React from 'react'; - -// DEFAULT_TAB is the first tab, which is selected in the application view. -export const DEFAULT_TAB = 'resources'; - -interface ITabsParams { - tab: string; - setTab(tab: string): void; - refResourcesContent: React.RefObject; - refMetricsContent: React.RefObject; - refLogsContent: React.RefObject; -} - -// Tabs renders the tabs header, which are used by the user to select a section he wants to view for an application. -// We can not use the tab state, within this component, because then the tab change isn't reflected in the TabsContent -// component. So that we have to manage the refs and tab in the parent component. -const Tabs: React.FunctionComponent = ({ - tab, - setTab, - refResourcesContent, - refMetricsContent, - refLogsContent, -}: ITabsParams) => { - return ( - setTab(tabIndex.toString())} - > - Resources} - tabContentId="refResources" - tabContentRef={refResourcesContent} - /> - Metrics} - tabContentId="refMetrics" - tabContentRef={refMetricsContent} - /> - Logs} - tabContentId="refLogs" - tabContentRef={refLogsContent} - /> - - ); -}; - -export default Tabs; diff --git a/app/src/components/applications/details/TabsContent.tsx b/app/src/components/applications/details/TabsContent.tsx deleted file mode 100644 index f767a9017..000000000 --- a/app/src/components/applications/details/TabsContent.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import React, { useState } from 'react'; -import { useHistory, useLocation } from 'react-router-dom'; -import { TabContent } from '@patternfly/react-core'; - -import { Application } from 'generated/proto/application_pb'; -import { IDatasourceOptions } from 'utils/proto'; -import Logs from 'components/applications/details/logs/Logs'; -import Metrics from 'components/applications/details/metrics/Metrics'; -import Resources from 'components/applications/details/resources/Resources'; - -// datasourceOptionsFromLocationSearch is used to parse all query parameters during the first rendering of the -// TabsContent component. When the parameters are not set we return some default options for the datasources. Because it -// could happen that only some parameters are set via location.search, we have to check each property if it contains a -// valid value. If this is the case we are overwriting the default value. -const datasourceOptionsFromLocationSearch = (): IDatasourceOptions => { - const params = new URLSearchParams(window.location.search); - return { - resolution: params.get('resolution') ? (params.get('resolution') as string) : '', - timeEnd: params.get('timeEnd') ? parseInt(params.get('timeEnd') as string) : Math.floor(Date.now() / 1000), - timeStart: params.get('timeStart') - ? parseInt(params.get('timeStart') as string) - : Math.floor(Date.now() / 1000) - 3600, - }; -}; - -// createSearch creates a string, which can be used within the history.push function as search parameter. For that we -// are looping over each key of the IDatasourceOptions interface and if it contains a value, this value will be added to -// the parameters. -const createSearch = (options: IDatasourceOptions): string => { - const params: string[] = []; - - let option: keyof IDatasourceOptions; - for (option in options) { - if (options[option]) { - params.push(`${option}=${options[option]}`); - } - } - - return `?${params.join('&')}`; -}; - -interface ITabsContent { - application: Application; - tab: string; - refResourcesContent: React.RefObject; - refMetricsContent: React.RefObject; - refLogsContent: React.RefObject; -} - -// TabsContent renders the content for a selected tab from the Tabs component. We also manage the datasource options, -// within this component, so that we can share the selected time range between metrics, logs and traces. -// When the datasource options are changed, we also reflect this change in the URL via query parameters, so that a user -// can share his current view with other users. -const TabsContent: React.FunctionComponent = ({ - application, - tab, - refResourcesContent, - refMetricsContent, - refLogsContent, -}: ITabsContent) => { - const history = useHistory(); - const location = useLocation(); - const [datasourceOptions, setDatasourceOptions] = useState(datasourceOptionsFromLocationSearch()); - - const changeDatasourceOptions = (options: IDatasourceOptions): void => { - setDatasourceOptions(options); - - history.push({ - pathname: location.pathname, - search: createSearch(options), - }); - }; - - return ( - - -
- -
-
- - - {/* We have to check if the refMetricsContent is not null, because otherwise the Metrics component will be shown below the resources component. */} -
- {refMetricsContent.current ? ( - - ) : null} -
-
- - - {/* We have to check if the refLogsContent is not null, because otherwise the Logs component will be shown below the resources component. */} -
- {refLogsContent.current ? ( - - ) : null} -
-
-
- ); -}; - -export default TabsContent; diff --git a/app/src/components/applications/details/logs/Elasticsearch.tsx b/app/src/components/applications/details/logs/Elasticsearch.tsx deleted file mode 100644 index fc217f901..000000000 --- a/app/src/components/applications/details/logs/Elasticsearch.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { Alert, AlertVariant, Button } from '@patternfly/react-core'; -import React, { useCallback, useEffect, useState } from 'react'; - -import { DatasourceLogsBucket, GetLogsRequest, GetLogsResponse } from 'generated/proto/datasources_pb'; -import { ApplicationLogsQuery } from 'generated/proto/application_pb'; -import Buckets from 'components/datasources/elasticsearch/Buckets'; -import { DatasourcesPromiseClient } from 'generated/proto/datasources_grpc_web_pb'; -import Documents from 'components/datasources/elasticsearch/Documents'; -import { IDatasourceOptions } from 'utils/proto'; -import { IDocument } from 'components/datasources/elasticsearch/helpers'; -import { apiURL } from 'utils/constants'; -import { convertDatasourceOptionsToProto } from 'utils/proto'; - -const datasourcesService = new DatasourcesPromiseClient(apiURL, null, null); - -export interface IElasticsearchProps { - query?: string; - fields?: string[]; - datasourceName: string; - datasourceType: string; - datasourceOptions: IDatasourceOptions; -} - -// Elasticsearhc implements the Elasticsearch UI for kobs. It can be used to query a configured Elasticsearch instance -// and show the logs in a table. -const Elasticsearch: React.FunctionComponent = ({ - query, - fields, - datasourceName, - datasourceType, - datasourceOptions, -}: IElasticsearchProps) => { - const [hits, setHits] = useState(0); - const [took, setTook] = useState(0); - const [documents, setDocuments] = useState([]); - const [buckets, setBuckets] = useState([]); - const [error, setError] = useState(''); - - // fetchLogs fetches the logs for a given query. For the applications view, we do not care about infinite scrolling. - // When a user wants to see more then the fetched logs, he has to go to the datasource view. - const fetchLogs = useCallback(async (): Promise => { - try { - if (query) { - const logsQuery = new ApplicationLogsQuery(); - logsQuery.setQuery(query); - - const getLogsRequest = new GetLogsRequest(); - getLogsRequest.setName(datasourceName); - getLogsRequest.setScrollid(''); - getLogsRequest.setOptions(convertDatasourceOptionsToProto(datasourceOptions)); - getLogsRequest.setQuery(logsQuery); - - const getLogsResponse: GetLogsResponse = await datasourcesService.getLogs(getLogsRequest, null); - - const parsed = JSON.parse(getLogsResponse.getLogs()); - if (parsed.length === 0) { - throw new Error('No documents were found'); - } else { - if (getLogsResponse.toObject().bucketsList.length > 0) { - setBuckets(getLogsResponse.toObject().bucketsList); - } - - setDocuments(parsed); - setHits(getLogsResponse.getHits()); - setTook(getLogsResponse.getTook()); - setError(''); - } - } - } catch (err) { - setError(err.message); - } - }, [query, datasourceName, datasourceOptions]); - - useEffect(() => { - fetchLogs(); - }, [fetchLogs]); - - return ( - - {error ? ( - -

 

- -

{error}

-
-
- ) : ( - -

 

- - {buckets.length > 0 ? : null} - -

 

- - {documents.length > 0 ? ( - - ) : null} - -

 

- - -
- )} -
- ); -}; - -export default Elasticsearch; diff --git a/app/src/components/applications/details/logs/Logs.tsx b/app/src/components/applications/details/logs/Logs.tsx deleted file mode 100644 index 36c397d63..000000000 --- a/app/src/components/applications/details/logs/Logs.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { Alert, AlertActionLink, AlertVariant } from '@patternfly/react-core'; -import React, { useCallback, useEffect, useState } from 'react'; -import ChartAreaIcon from '@patternfly/react-icons/dist/js/icons/chart-area-icon'; - -import { Application, ApplicationLogsQuery } from 'generated/proto/application_pb'; -import { GetDatasourceRequest, GetDatasourceResponse } from 'generated/proto/datasources_pb'; -import { DatasourcesPromiseClient } from 'generated/proto/datasources_grpc_web_pb'; -import Elasticsearch from 'components/applications/details/logs/Elasticsearch'; -import { IDatasourceOptions } from 'utils/proto'; -import NotDefined from 'components/applications/details/NotDefined'; -import Toolbar from 'components/applications/details/logs/Toolbar'; -import { apiURL } from 'utils/constants'; - -const datasourcesService = new DatasourcesPromiseClient(apiURL, null, null); - -interface ILogsProps { - datasourceOptions: IDatasourceOptions; - setDatasourceOptions: (options: IDatasourceOptions) => void; - application: Application; -} - -// Logs is the component, which is shown inside the logs tab of an application. It is used as wrapper component for the -// toolbar and results component. For the results we show different components, depending on the datasource type. -const Logs: React.FunctionComponent = ({ - datasourceOptions, - setDatasourceOptions, - application, -}: ILogsProps) => { - const logs = application.getLogs(); - - const [datasourceName, setDatasourceName] = useState(''); - const [datasourceType, setDatasourceType] = useState(''); - const [query, setQuery] = useState(logs ? logs.getQueriesList()[0] : undefined); - const [error, setError] = useState(''); - - // fetchDatasourceDetails fetch all details, which are specific for a datasource. Currently this is only the type of - // the datasource, but can be extended in the future if needed. More information can be found in the datasources.proto - // file and the documentation for the GetDatasourceResponse message format. - const fetchDatasourceDetails = useCallback(async () => { - try { - if (!logs) { - throw new Error('Logs are not defined.'); - } else { - const getDatasourceRequest = new GetDatasourceRequest(); - getDatasourceRequest.setName(logs.getDatasource()); - - const getDatasourceResponse: GetDatasourceResponse = await datasourcesService.getDatasource( - getDatasourceRequest, - null, - ); - - const datasource = getDatasourceResponse.getDatasource(); - if (datasource) { - setDatasourceName(datasource.getName()); - setDatasourceType(datasource.getType()); - setError(''); - } else { - throw new Error('Datasource is not defined.'); - } - } - } catch (err) { - setError(err.message); - } - }, [logs]); - - useEffect(() => { - fetchDatasourceDetails(); - }, [fetchDatasourceDetails]); - - // If the logs seticon in the Application CR isn't defined, we return the NotDefined component, with a link to the - // documentation, where a user can find more information on who to define logs. - if (!logs) { - return ( - - ); - } - - // If an error occured during, we show the user the error, with an option to retry the request. - if (error) { - return ( - - Retry -
- } - > -

{error}

- - ); - } - - return ( -
- setQuery(q)} - /> - - {datasourceType === 'elasticsearch' ? ( - - ) : null} -
- ); -}; - -export default Logs; diff --git a/app/src/components/applications/details/logs/Toolbar.tsx b/app/src/components/applications/details/logs/Toolbar.tsx deleted file mode 100644 index fc482345a..000000000 --- a/app/src/components/applications/details/logs/Toolbar.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { - Dropdown, - DropdownItem, - DropdownToggle, - Toolbar as PatternflyToolbar, - ToolbarContent, - ToolbarGroup, - ToolbarItem, - ToolbarToggleGroup, -} from '@patternfly/react-core'; -import React, { useState } from 'react'; -import CaretDownIcon from '@patternfly/react-icons/dist/js/icons/caret-down-icon'; -import FilterIcon from '@patternfly/react-icons/dist/js/icons/filter-icon'; - -import { ApplicationLogsQuery } from 'generated/proto/application_pb'; -import { IDatasourceOptions } from 'utils/proto'; -import Options from 'components/applications/details/metrics/Options'; - -interface IToolbarProps { - datasourcenName: string; - datasourceType: string; - datasourceOptions: IDatasourceOptions; - setDatasourceOptions: (options: IDatasourceOptions) => void; - queries: ApplicationLogsQuery[]; - query?: ApplicationLogsQuery; - selectQuery: (q: ApplicationLogsQuery) => void; -} - -// Toolbar shows the options for a logs datasource, where the user can select the time range for which he wants to fetch -// the logs. The user can also select a defined query for an application. -const Toolbar: React.FunctionComponent = ({ - datasourcenName, - datasourceType, - datasourceOptions, - setDatasourceOptions, - queries, - query, - selectQuery, -}: IToolbarProps) => { - const [show, setShow] = useState(false); - - // selectDropdownItem is the function is called, when a user selects a query from the dropdown component. When a query - // is selected, we set this query as the current query and we close the dropdown. - const selectDropdownItem = (q: ApplicationLogsQuery): void => { - selectQuery(q); - setShow(false); - }; - - return ( - - - } breakpoint="lg"> - - - setShow(!show)} - toggleIndicator={CaretDownIcon} - > - {query ? query.getName() : 'Queries'} - - } - isOpen={show} - dropdownItems={queries.map((q, index) => ( - selectDropdownItem(q)} - description={q.getQuery()} - > - {q.getName()} - - ))} - /> - - - - - - - - - - - ); -}; - -export default Toolbar; diff --git a/app/src/components/applications/details/metrics/Chart.tsx b/app/src/components/applications/details/metrics/Chart.tsx deleted file mode 100644 index 27531ad61..000000000 --- a/app/src/components/applications/details/metrics/Chart.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { - Alert, - AlertActionLink, - AlertVariant, - Card, - CardActions, - CardBody, - CardHeader, - CardHeaderMain, -} from '@patternfly/react-core'; -import React, { useCallback, useEffect, useState } from 'react'; - -import { DatasourceMetrics, GetMetricsRequest, GetMetricsResponse } from 'generated/proto/datasources_pb'; -import { - IApplicationMetricsVariable, - IDatasourceOptions, - convertApplicationMetricsVariablesToProto, - convertDatasourceOptionsToProto, -} from 'utils/proto'; -import Actions from 'components/applications/details/metrics/charts/Actions'; -import { ApplicationMetricsChart } from 'generated/proto/application_pb'; -import { DatasourcesPromiseClient } from 'generated/proto/datasources_grpc_web_pb'; -import DefaultChart from 'components/applications/details/metrics/charts/Default'; -import EmptyStateSpinner from 'components/shared/EmptyStateSpinner'; -import SparklineChart from 'components/applications/details/metrics/charts/Sparkline'; -import { apiURL } from 'utils/constants'; - -const datasourcesService = new DatasourcesPromiseClient(apiURL, null, null); - -interface IChartProps { - datasourceName: string; - datasourceType: string; - datasourceOptions: IDatasourceOptions; - variables: IApplicationMetricsVariable[]; - chart: ApplicationMetricsChart; -} - -// Chart component is used to fetch the data for an chart and to render the chart within a Card component. -const Chart: React.FunctionComponent = ({ - datasourceName, - datasourceType, - datasourceOptions, - variables, - chart, -}: IChartProps) => { - const [data, setData] = useState([]); - const [interpolatedQueries, setInterpolatedQueries] = useState([]); - const [error, setError] = useState(''); - - // fetchData fetchs the data for a chart. If the gRPC call returns an error, we catch the error and set the - // corresponding error state. - const fetchData = useCallback(async () => { - try { - if (datasourceName !== '' && chart.getQueriesList().length > 0) { - const getMetricsRequest = new GetMetricsRequest(); - getMetricsRequest.setName(datasourceName); - getMetricsRequest.setOptions(convertDatasourceOptionsToProto(datasourceOptions)); - getMetricsRequest.setVariablesList(convertApplicationMetricsVariablesToProto(variables)); - getMetricsRequest.setQueriesList(chart.getQueriesList()); - - const getMetricsResponse: GetMetricsResponse = await datasourcesService.getMetrics(getMetricsRequest, null); - - setInterpolatedQueries(getMetricsResponse.getInterpolatedqueriesList()); - setData(getMetricsResponse.getMetricsList()); - setError(''); - } - } catch (err) { - setError(err.message); - } - }, [datasourceName, datasourceOptions, variables, chart]); - - useEffect(() => { - fetchData(); - }, [fetchData]); - - // If an error occured during the gRPC call we show the error message in the card body. - if (error) { - return ( - - - {chart.getTitle()} - - - - Retry - - } - > -

{error}

-
-
-
- ); - } - - // If the data length is zero, we show the empty state component with a spinner, because this only can happen on the - // first render. When the fetchData all was executed the data must be set or the error will be rendered befor this. - if (data.length === 0) { - return ( - - - {chart.getTitle()} - - - - - - ); - } - - return ( - - - {chart.getTitle()} - - - - - - {chart.getType() === 'sparkline' ? ( - - ) : ( - - )} - - - ); -}; - -export default Chart; diff --git a/app/src/components/applications/details/metrics/Charts.tsx b/app/src/components/applications/details/metrics/Charts.tsx deleted file mode 100644 index 3b020e2a4..000000000 --- a/app/src/components/applications/details/metrics/Charts.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { Grid, GridItem, Title, gridSpans } from '@patternfly/react-core'; -import React, { useEffect, useRef, useState } from 'react'; - -import { IApplicationMetricsVariable, IDatasourceOptions } from 'utils/proto'; -import { ApplicationMetricsChart } from 'generated/proto/application_pb'; -import Chart from 'components/applications/details/metrics/Chart'; - -interface IChartsProps { - datasourceName: string; - datasourceType: string; - datasourceOptions: IDatasourceOptions; - variables: IApplicationMetricsVariable[]; - charts: ApplicationMetricsChart[]; -} - -// Charts renders a Grid of the user defined charts for an applicatication. The grid contains a small padding to the -// toolbar, which is rendered above. When the width of the grid is larger then 1200px, we apply the user defined size -// for each chart. If the width is below this value each chart will be rendered accross the complete width of the grid. -const Charts: React.FunctionComponent = ({ - datasourceName, - datasourceType, - datasourceOptions, - variables, - charts, -}: IChartsProps) => { - const refGrid = useRef(null); - const [width, setWidth] = useState(0); - - // useEffect is executed on every render, to determin the size of the grid and apply the user defined size for charts - // if necessary. - useEffect(() => { - if (refGrid && refGrid.current) { - setWidth(refGrid.current.getBoundingClientRect().width); - } - }, []); - - return ( -
- - {charts.map((chart, index) => ( - = 1200 && chart.getSize() > 0 && chart.getSize() <= 12 && chart.getType() !== 'divider' - ? (chart.getSize() as gridSpans) - : 12 - } - > - {chart.getType() === 'divider' ? ( - - {chart.getTitle()} - - ) : ( - - )} - - ))} - -
- ); -}; - -export default Charts; diff --git a/app/src/components/applications/details/metrics/Metrics.tsx b/app/src/components/applications/details/metrics/Metrics.tsx deleted file mode 100644 index 5e463786e..000000000 --- a/app/src/components/applications/details/metrics/Metrics.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { Alert, AlertActionLink, AlertVariant } from '@patternfly/react-core'; -import React, { useCallback, useEffect, useState } from 'react'; -import ChartAreaIcon from '@patternfly/react-icons/dist/js/icons/chart-area-icon'; - -import { GetDatasourceRequest, GetDatasourceResponse } from 'generated/proto/datasources_pb'; -import { - IApplicationMetricsVariable, - IDatasourceOptions, - convertApplicationMetricsVariablesFromProto, -} from 'utils/proto'; -import { Application } from 'generated/proto/application_pb'; -import Charts from 'components/applications/details/metrics/Charts'; -import { DatasourcesPromiseClient } from 'generated/proto/datasources_grpc_web_pb'; -import NotDefined from 'components/applications/details/NotDefined'; -import Toolbar from 'components/applications/details/metrics/Toolbar'; -import { apiURL } from 'utils/constants'; - -const datasourcesService = new DatasourcesPromiseClient(apiURL, null, null); - -interface IMetricsProps { - datasourceOptions: IDatasourceOptions; - setDatasourceOptions: (options: IDatasourceOptions) => void; - application: Application; -} - -// Metrics the metrics component is used to display the metrics for an application. The metrics view consist of a -// toolbar, to display variables and different datasource specific options for the queries. It also contains a charts -// view, to display all user defined charts. -const Metrics: React.FunctionComponent = ({ - datasourceOptions, - setDatasourceOptions, - application, -}: IMetricsProps) => { - const metrics = application.getMetrics(); - - const [datasourceName, setDatasourceName] = useState(''); - const [datasourceType, setDatasourceType] = useState(''); - const [variables, setVariables] = useState( - metrics ? convertApplicationMetricsVariablesFromProto(metrics.getVariablesList()) : [], - ); - const [error, setError] = useState(''); - - // fetchDatasourceDetails fetch all details, which are specific for a datasource. Currently this is only the type of - // the datasource, but can be extended in the future if needed. More information can be found in the datasources.proto - // file and the documentation for the GetDatasourceResponse message format. - const fetchDatasourceDetails = useCallback(async () => { - try { - if (!metrics) { - throw new Error('Metrics are not defined.'); - } else { - const getDatasourceRequest = new GetDatasourceRequest(); - getDatasourceRequest.setName(metrics.getDatasource()); - - const getDatasourceResponse: GetDatasourceResponse = await datasourcesService.getDatasource( - getDatasourceRequest, - null, - ); - - const datasource = getDatasourceResponse.getDatasource(); - if (datasource) { - setDatasourceName(datasource.getName()); - setDatasourceType(datasource.getType()); - setError(''); - } else { - throw new Error('Datasource is not defined.'); - } - } - } catch (err) { - setError(err.message); - } - }, [metrics]); - - useEffect(() => { - fetchDatasourceDetails(); - }, [fetchDatasourceDetails]); - - // If the metrics seticon in the Application CR isn't defined, we return the NotDefined component, with a link to the - // documentation, where a user can find more information on who to define metrics. - if (!metrics) { - return ( - - ); - } - - // If an error occured during, we show the user the error, with an option to retry the request. - if (error) { - return ( - - Retry - - } - > -

{error}

-
- ); - } - - return ( - - setVariables(vars)} - /> - - - - ); -}; - -export default Metrics; diff --git a/app/src/components/applications/details/metrics/Options.tsx b/app/src/components/applications/details/metrics/Options.tsx deleted file mode 100644 index ea9c0b4a2..000000000 --- a/app/src/components/applications/details/metrics/Options.tsx +++ /dev/null @@ -1,196 +0,0 @@ -import { - Button, - ButtonVariant, - Form, - FormGroup, - Level, - LevelItem, - Modal, - ModalVariant, - SimpleList, - SimpleListItem, - TextInput, -} from '@patternfly/react-core'; -import React, { useEffect, useState } from 'react'; - -import { IDatasourceOptions } from 'utils/proto'; -import { formatTime } from 'utils/helpers'; - -interface IOptionsProps { - type: string; - options: IDatasourceOptions; - setOptions: (options: IDatasourceOptions) => void; -} - -// Options is the component, where the user can select various options for the current view. The user can select a time -// range for all queries via the quick select option or he can specify a start and end time via the input fields. Later -// we can also display datasource specific options within the modal component. -const Options: React.FunctionComponent = ({ type, options, setOptions }: IOptionsProps) => { - const [show, setShow] = useState(false); - const [timeStart, setTimeStart] = useState(formatTime(options.timeStart)); - const [timeEnd, setTimeEnd] = useState(formatTime(options.timeEnd)); - const [timeStartError, setTimeStartError] = useState(''); - const [timeEndError, setTimeEndError] = useState(''); - - const [resolution, setResolution] = useState(options.resolution ? options.resolution : ''); - - // apply parses the value of the start and end input fields. If the user provided a correct data/time format, we - // change the start and end time to the new values. If the string couldn't be parsed, the user will see an error below - // the corresponding input field. - const apply = (): void => { - // Get a new date object for the users current timezone. This allows us to ignore the timezone, while parsing the - // provided time string. The parsed date object will be in UTC, to transform the parsed date into the users timezone - // we have to add the minutes between UTC and the users timezon (getTimezoneOffset()). - const d = new Date(); - - const parsedTimeStart = new Date(timeStart.replace(' ', 'T') + 'Z'); - const parsedTimeEnd = new Date(timeEnd.replace(' ', 'T') + 'Z'); - - parsedTimeStart.setMinutes(parsedTimeStart.getMinutes() + d.getTimezoneOffset()); - parsedTimeEnd.setMinutes(parsedTimeEnd.getMinutes() + d.getTimezoneOffset()); - - if (parsedTimeStart.toString() === 'Invalid Date') { - setTimeStartError('Invalid time format.'); - setTimeEndError(''); - } else if (parsedTimeEnd.toString() === 'Invalid Date') { - setTimeStartError(''); - setTimeEndError('Invalid time format.'); - } else { - setTimeStartError(''); - setTimeEndError(''); - setOptions({ - ...options, - resolution: resolution, - timeEnd: Math.floor(parsedTimeEnd.getTime() / 1000), - timeStart: Math.floor(parsedTimeStart.getTime() / 1000), - }); - setShow(false); - } - }; - - // quick is the function for the quick select option. We always use the current time in seconds and substract the - // seconds specified in the quick select option. - const quick = (seconds: number): void => { - setOptions({ - ...options, - resolution: resolution, - timeEnd: Math.floor(Date.now() / 1000), - timeStart: Math.floor(Date.now() / 1000) - seconds, - }); - setShow(false); - }; - - // useEffect is used to update the UI, every time a dasource options changes. - useEffect(() => { - setTimeStart(formatTime(options.timeStart)); - setTimeEnd(formatTime(options.timeEnd)); - - setResolution(options.resolution ? options.resolution : ''); - }, [options.timeEnd, options.timeStart, options.resolution]); - - return ( - - - setShow(false)} - actions={[ - , - , - ]} - > - - -
- - setTimeStart(value)} - /> - - - setTimeEnd(value)} - /> - -
-
- - - quick(300)}>Last 5 Minutes - quick(900)}>Last 15 Minutes - quick(1800)}>Last 30 Minutes - quick(3600)}>Last 1 Hour - quick(10800)}>Last 3 Hours - quick(21600)}>Last 6 Hours - quick(43200)}>Last 12 Hours - - - - - quick(86400)}>Last 1 Day - quick(172800)}>Last 2 Days - quick(604800)}>Last 7 Days - quick(2592000)}>Last 30 Days - quick(7776000)}>Last 90 Days - quick(15552000)}>Last 6 Months - quick(31536000)}>Last 1 Year - - - {type === 'prometheus' ? ( - -
- - setResolution(value)} - /> - -
-
- ) : null} -
-
-
- ); -}; - -export default Options; diff --git a/app/src/components/applications/details/metrics/Toolbar.tsx b/app/src/components/applications/details/metrics/Toolbar.tsx deleted file mode 100644 index 0cc726040..000000000 --- a/app/src/components/applications/details/metrics/Toolbar.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { - Alert, - AlertActionLink, - AlertVariant, - Toolbar as PatternflyToolbar, - ToolbarContent, - ToolbarGroup, - ToolbarItem, - ToolbarToggleGroup, -} from '@patternfly/react-core'; -import React, { useCallback, useEffect, useState } from 'react'; -import FilterIcon from '@patternfly/react-icons/dist/js/icons/filter-icon'; - -import { GetVariablesRequest, GetVariablesResponse } from 'generated/proto/datasources_pb'; -import { - IApplicationMetricsVariable, - IDatasourceOptions, - convertApplicationMetricsVariablesFromProto, - convertApplicationMetricsVariablesToProto, - convertDatasourceOptionsToProto, -} from 'utils/proto'; -import { DatasourcesPromiseClient } from 'generated/proto/datasources_grpc_web_pb'; -import Options from 'components/applications/details/metrics/Options'; -import Variable from 'components/applications/details/metrics/Variable'; -import { apiURL } from 'utils/constants'; - -const datasourcesService = new DatasourcesPromiseClient(apiURL, null, null); - -interface IToolbarProps { - datasourcenName: string; - datasourceType: string; - datasourceOptions: IDatasourceOptions; - setDatasourceOptions: (options: IDatasourceOptions) => void; - variables: IApplicationMetricsVariable[]; - setVariables: (variables: IApplicationMetricsVariable[]) => void; -} - -// Toolbar component displays a list of all variables and an options field. The variables are displayed via a dropdown -// and can be selected by the user. If the user selects a new value, the variables property will be changed via the -// setVariables function, so that the change is also propergated to the corresponding charts. The same counts for the -// datasource options. -const Toolbar: React.FunctionComponent = ({ - datasourcenName, - datasourceType, - datasourceOptions, - setDatasourceOptions, - variables, - setVariables, -}: IToolbarProps) => { - const [error, setError] = useState(''); - - // onSelectVariableValue is executed, when the user selects a new value for a variable. It will create a copy of the - // current variables, changes the value and sets the new values in the parent component. - const onSelectVariableValue = (value: string, index: number): void => { - const tmpVariables = [...variables]; - tmpVariables[index].value = value; - setVariables(tmpVariables); - }; - - // fetchVariables is used to fetch all values for all variables. When we successfully fetched all values we change, - // the passed in variables property. - // HACK: Since this ends in an endless rerendering and fetching we have omit the setVariables in the dependency array. - // We also have to compare the JSON representation of the variables prop and the loaded variables, to omit unnecessary - // rerenderings. Maybe we can also do this before the fetch, so that we do not fetch the variables twice. - const fetchVariables = useCallback(async () => { - try { - if (datasourcenName !== '' && variables.length > 0) { - const getVariablesRequest = new GetVariablesRequest(); - getVariablesRequest.setName(datasourcenName); - getVariablesRequest.setOptions(convertDatasourceOptionsToProto(datasourceOptions)); - getVariablesRequest.setVariablesList(convertApplicationMetricsVariablesToProto(variables)); - - const getVariablesResponse: GetVariablesResponse = await datasourcesService.getVariables( - getVariablesRequest, - null, - ); - - const tmpVariables = convertApplicationMetricsVariablesFromProto(getVariablesResponse.getVariablesList()); - if (JSON.stringify(tmpVariables) !== JSON.stringify(variables)) { - setVariables(tmpVariables); - } - setError(''); - } - } catch (err) { - setError(err.message); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [datasourcenName, datasourceOptions, variables]); - - useEffect(() => { - fetchVariables(); - }, [fetchVariables]); - - // If an error occured during, we show the user the error, with an option to retry the request. - if (error) { - return ( - - Retry - - } - > -

{error}

-
- ); - } - - return ( - - - } breakpoint="lg"> - - {variables.map((variable, index) => ( - - onSelectVariableValue(value, index)} - /> - - ))} - - - - - - - - - - ); -}; - -export default Toolbar; diff --git a/app/src/components/applications/details/metrics/Variable.tsx b/app/src/components/applications/details/metrics/Variable.tsx deleted file mode 100644 index def897867..000000000 --- a/app/src/components/applications/details/metrics/Variable.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React, { useState } from 'react'; -import { Select, SelectOption, SelectOptionObject, SelectVariant } from '@patternfly/react-core'; - -import { IApplicationMetricsVariable } from 'utils/proto'; - -interface IVariableProps { - variable: IApplicationMetricsVariable; - selectValue: (value: string) => void; -} - -// Variable is the component tp render a single variable in a Select component. When the user selects a new value, via -// use the passed in selectValue function to change the variable. -const Variable: React.FunctionComponent = ({ variable, selectValue }: IVariableProps) => { - const [show, setShow] = useState(false); - - const onSelect = ( - event: React.MouseEvent | React.ChangeEvent, - value: string | SelectOptionObject, - ): void => { - selectValue(value as string); - setShow(false); - }; - - return ( - - ); -}; - -export default Variable; diff --git a/app/src/components/applications/details/metrics/charts/Actions.tsx b/app/src/components/applications/details/metrics/charts/Actions.tsx deleted file mode 100644 index e8747f048..000000000 --- a/app/src/components/applications/details/metrics/charts/Actions.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Dropdown, DropdownItem, KebabToggle } from '@patternfly/react-core'; -import React, { useState } from 'react'; -import { Link } from 'react-router-dom'; - -import { IDatasourceOptions } from 'utils/proto'; - -interface IActionsProps { - datasourceName: string; - datasourceType: string; - datasourceOptions: IDatasourceOptions; - interpolatedQueries: string[]; -} - -// Actions is a dropdown component, which provides various actions for a chart. For example it can be used to display a -// link for each query in the chart. When the user click on the link, he will be redirected to the corresponding -// datasource page, with the query and time data prefilled. -const Actions: React.FunctionComponent = ({ - datasourceName, - datasourceType, - datasourceOptions, - interpolatedQueries, -}: IActionsProps) => { - const [show, setShow] = useState(false); - - return ( - setShow(!show)} />} - isOpen={show} - isPlain={true} - position="right" - dropdownItems={interpolatedQueries.map((query, index) => ( - - Explore {query} - - } - /> - ))} - /> - ); -}; - -export default Actions; diff --git a/app/src/components/applications/details/metrics/charts/Default.tsx b/app/src/components/applications/details/metrics/charts/Default.tsx deleted file mode 100644 index 9d13eb9c9..000000000 --- a/app/src/components/applications/details/metrics/charts/Default.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { - Chart, - ChartArea, - ChartAxis, - ChartBar, - ChartGroup, - ChartLegendTooltip, - ChartLine, - ChartStack, - ChartThemeColor, - createContainer, -} from '@patternfly/react-charts'; -import React, { useEffect, useRef, useState } from 'react'; - -import { DatasourceMetrics } from 'generated/proto/datasources_pb'; -import { IDatasourceMetricsData } from 'utils/proto'; -import { formatTime } from 'utils/helpers'; - -interface ILabels { - datum: IDatasourceMetricsData; -} - -export interface IDefaultProps { - type: string; - unit: string; - stacked: boolean; - disableLegend?: boolean; - metrics: DatasourceMetrics[]; -} - -// Default represents our default chart types: area, bar and line chart. We display the user defined unit at the y axis -// of the chart. If the user enabled the stacked option the chart is wrapped in a ChartStack instead of the ChartGroup -// component. -// The documentation for the different chart types can be found in the Patternfly documentation: -// - Area Chart: https://www.patternfly.org/v4/charts/area-chart -// - Bar Chart: https://www.patternfly.org/v4/charts/bar-chart -// - Line Chart: https://www.patternfly.org/v4/charts/line-chart -// -// NOTE: Currently it is not possible to select a single time series in the chart. This should be changed in the future, -// by using an interactive legend: https://www.patternfly.org/v4/charts/legend-chart#interactive-legend -const Default: React.FunctionComponent = ({ - type, - unit, - stacked, - disableLegend, - metrics, -}: IDefaultProps) => { - const refChart = useRef(null); - const [width, setWidth] = useState(0); - const [height, setHeight] = useState(0); - - // useEffect is executed on every render of this component. This is needed, so that we are able to use a width of 100% - // and a static height for the chart. - useEffect(() => { - if (refChart && refChart.current) { - setWidth(refChart.current.getBoundingClientRect().width); - setHeight(refChart.current.getBoundingClientRect().height); - } - }, []); - - const CursorVoronoiContainer = createContainer('voronoi', 'cursor'); - const legendData = metrics.map((metric, index) => ({ childName: `index${index}`, name: metric.getLabel() })); - const series = metrics.map((metric, index) => - type === 'area' ? ( - - ) : type === 'bar' ? ( - - ) : ( - - ), - ); - - return ( -
- `${datum.y} ${unit}`} - labelComponent={ - formatTime(Math.floor(point.x / 1000))} - /> - } - mouseFollowTooltips - voronoiDimension="x" - voronoiPadding={0} - /> - } - height={height} - legendData={legendData} - legendPosition={disableLegend ? undefined : 'bottom'} - padding={{ bottom: disableLegend ? 0 : 60, left: 60, right: 0, top: 0 }} - scale={{ x: 'time', y: 'linear' }} - themeColor={ChartThemeColor.multiOrdered} - width={width} - > - - - {stacked ? {series} : {series}} - -
- ); -}; - -export default Default; diff --git a/app/src/components/applications/details/metrics/charts/Sparkline.tsx b/app/src/components/applications/details/metrics/charts/Sparkline.tsx deleted file mode 100644 index 8fa1265dd..000000000 --- a/app/src/components/applications/details/metrics/charts/Sparkline.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { ChartArea, ChartGroup } from '@patternfly/react-charts'; -import React, { useEffect, useRef, useState } from 'react'; - -import { DatasourceMetrics } from 'generated/proto/datasources_pb'; - -export interface ISparklineProps { - unit: string; - metrics: DatasourceMetrics[]; -} - -// Sparkline displays a sparkline chart. The complete documentation for sparklines can be found in the Patternfly -// documentation https://www.patternfly.org/v4/charts/sparkline-chart. We also display the last/current value in the -// center of the sparkline, including the user defined unit. -const Sparkline: React.FunctionComponent = ({ unit, metrics }: ISparklineProps) => { - const refChart = useRef(null); - const [width, setWidth] = useState(0); - const [height, setHeight] = useState(0); - - // useEffect is executed on every render of this component. This is needed, so that we are able to use a width of 100% - // and a static height for the chart. - useEffect(() => { - if (refChart && refChart.current) { - setWidth(refChart.current.getBoundingClientRect().width); - setHeight(refChart.current.getBoundingClientRect().height); - } - }, []); - - return ( -
-
- {metrics[0].getDataList()[metrics[0].getDataList().length - 1].getY()} {unit} -
- - {metrics.map((metric, index) => ( - - ))} - -
- ); -}; - -export default Sparkline; diff --git a/app/src/components/applications/details/resources/Resource.tsx b/app/src/components/applications/details/resources/Resource.tsx deleted file mode 100644 index 3b609833a..000000000 --- a/app/src/components/applications/details/resources/Resource.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { IRow, Table, TableBody, TableHeader } from '@patternfly/react-table'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; - -import { GetResourcesRequest, GetResourcesResponse } from 'generated/proto/clusters_pb'; -import { emptyState, resources } from 'components/resources/shared/helpers'; -import { ClustersPromiseClient } from 'generated/proto/clusters_grpc_web_pb'; -import { apiURL } from 'utils/constants'; - -const clustersService = new ClustersPromiseClient(apiURL, null, null); - -interface IResourceProps { - cluster: string; - namespace: string; - kind: string; - selector: string; -} - -// Resource loads a list of resources for an application and shows them in a table. If the list of loaded recources is -// zero, we will display an empty state. -const Resource: React.FunctionComponent = ({ cluster, namespace, kind, selector }: IResourceProps) => { - const columns = useMemo( - () => (resources.hasOwnProperty(kind) ? resources[kind].columns : ['Name', 'Namespace', 'Cluster']), - [kind], - ); - const [rows, setRows] = useState(emptyState(columns.length, '')); - - // fetchResources is used to get all resources from the gRPC API for an specific application. For that we have to set - // the cluster, namespace and name of the application. Besides that the CR also defines the kind which should be - // loaded and the labelSelector. - const fetchResources = useCallback(async () => { - try { - const getResourcesRequest = new GetResourcesRequest(); - getResourcesRequest.setClustersList([cluster]); - getResourcesRequest.setNamespacesList([namespace]); - getResourcesRequest.setPath(resources[kind].path); - getResourcesRequest.setResource(resources[kind].resource); - getResourcesRequest.setParamname('labelSelector'); - getResourcesRequest.setParam(selector); - - const getResourcesResponse: GetResourcesResponse = await clustersService.getResources(getResourcesRequest, null); - const tmpRows = resources[kind].rows(getResourcesResponse.getResourcesList()); - - if (tmpRows.length > 0) { - setRows(tmpRows); - } else { - setRows(emptyState(columns.length, '')); - } - } catch (err) { - setRows(emptyState(columns.length, err.message)); - } - }, [cluster, namespace, kind, selector, columns]); - - useEffect(() => { - fetchResources(); - }, [fetchResources]); - - return ( - - - -
- ); -}; - -export default Resource; diff --git a/app/src/components/applications/details/resources/Resources.tsx b/app/src/components/applications/details/resources/Resources.tsx deleted file mode 100644 index 7e316dfdf..000000000 --- a/app/src/components/applications/details/resources/Resources.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { Accordion, AccordionContent, AccordionItem, AccordionToggle } from '@patternfly/react-core'; -import React, { useState } from 'react'; -import CubesIcon from '@patternfly/react-icons/dist/js/icons/cubes-icon'; - -import { Application } from 'generated/proto/application_pb'; -import NotDefined from 'components/applications/details/NotDefined'; -import Resource from 'components/applications/details/resources/Resource'; -import { resources } from 'components/resources/shared/helpers'; - -interface IResourcesProps { - application: Application; -} - -// Resources is the component to show all resources, which are associated to the application. The resource are grouped -// by their kind in an accordion view. -const Resources: React.FunctionComponent = ({ application }: IResourcesProps) => { - const [expanded, setExpanded] = useState([]); - - // toogle is used to show / hide a selected kind. When the kind is already present in the expanded state array it will - // be removed. If not it will be added. - const toggle = (id: string): void => { - if (expanded.includes(id)) { - setExpanded(expanded.filter((item) => item !== id)); - } else { - setExpanded([...expanded, id]); - } - }; - - // If the length of the resource list is zero, we will show the NotDefined component, with a link to the documentation - // on how to define resources within the Application CR. - if (application.getResourcesList().length === 0) { - return ( - - ); - } - - return ( - - {application.getResourcesList().map((resource, i) => ( -
- {resource.getKindsList().map((kind, j) => ( - - toggle(`resources-accordion-${i}-${j}`)} - isExpanded={expanded.includes(`resources-accordion-${i}-${j}`)} - id={`resources-toggle-${i}-${j}`} - > - {resources[kind].title} - - - - - - ))} -
- ))} -
- ); -}; - -export default Resources; diff --git a/app/src/components/applications/overview/Card.tsx b/app/src/components/applications/overview/Card.tsx deleted file mode 100644 index 736ac7410..000000000 --- a/app/src/components/applications/overview/Card.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { CardBody, CardTitle, Card as PatternflyCard } from '@patternfly/react-core'; -import React, { useCallback, useEffect, useState } from 'react'; - -import { - DatasourceMetrics, - DatasourceOptions, - GetMetricsRequest, - GetMetricsResponse, -} from 'generated/proto/datasources_pb'; -import { Application } from 'generated/proto/application_pb'; -import Chart from 'components/applications/overview/Chart'; -import { DatasourcesPromiseClient } from 'generated/proto/datasources_grpc_web_pb'; -import { apiURL } from 'utils/constants'; - -const datasourcesService = new DatasourcesPromiseClient(apiURL, null, null); - -interface ICardProps { - application: Application; - select: (application: Application) => void; -} - -// Card displays a single application within the Applications gallery. The component requires the application and a -// select function as props. The select function is called, when the user clicks on the card. In the applications pages, -// this will then open the drawer with the selected application. -const Card: React.FunctionComponent = ({ application, select }: ICardProps) => { - const metrics = application.getMetrics(); - - const [data, setData] = useState([]); - const [error, setError] = useState(''); - - // fetchData is used to fetch the data for the health metric of an Application. The data is then displayed as - // sparkline within the card for the application in the gallery. - const fetchData = useCallback(async () => { - try { - if (metrics && metrics.hasHealth()) { - const options = new DatasourceOptions(); - options.setTimeend(Math.floor(Date.now() / 1000)); - options.setTimestart(Math.floor(Date.now() / 1000) - 3600); - - const getMetricsRequest = new GetMetricsRequest(); - getMetricsRequest.setName(metrics.getDatasource()); - getMetricsRequest.setOptions(options); - - const health = metrics.getHealth(); - if (health) { - getMetricsRequest.setQueriesList(health.getQueriesList()); - } - - const getMetricsResponse: GetMetricsResponse = await datasourcesService.getMetrics(getMetricsRequest, null); - - setData(getMetricsResponse.getMetricsList()); - setError(''); - } - } catch (err) { - setError(err.message); - } - }, [metrics]); - - useEffect(() => { - fetchData(); - }, [fetchData]); - - // The card for an application can have three different state. When an error occured during the data fetch, we display - // this error on the card. When all data was successfully loaded we display the sparkline. If the application doesn't - // define a health chart, we just display the namespace and cluster for the application in the card body. - return ( - select(application)}> - {application.getName()} - {error ? ( - Could not get health data: {error} - ) : data.length > 0 && metrics && metrics.hasHealth() ? ( - - - - ) : ( - -
Namespace: {application.getNamespace()}
-
Cluster: {application.getCluster()}
-
- )} -
- ); -}; - -export default Card; diff --git a/app/src/components/applications/overview/Chart.tsx b/app/src/components/applications/overview/Chart.tsx deleted file mode 100644 index bd01b75fb..000000000 --- a/app/src/components/applications/overview/Chart.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { ChartArea, ChartGroup } from '@patternfly/react-charts'; -import React, { useEffect, useRef, useState } from 'react'; - -import { DatasourceMetrics } from 'generated/proto/datasources_pb'; - -export interface IChartProps { - title?: string; - unit?: string; - metrics: DatasourceMetrics[]; -} - -// Chart is used to render a sparkline on the card of an application in the overview gallery. Above the sparkline we -// show the last/current value of the metric and the chart title. -const Chart: React.FunctionComponent = ({ title, unit, metrics }: IChartProps) => { - const refChart = useRef(null); - const [width, setWidth] = useState(0); - const [height, setHeight] = useState(0); - - // useEffect is executed on every render of this component. This is needed, so that we are able to use a width of 100% - // and a static height for the chart. - useEffect(() => { - if (refChart && refChart.current) { - setWidth(refChart.current.getBoundingClientRect().width); - setHeight(refChart.current.getBoundingClientRect().height); - } - }, []); - - return ( - -
- {metrics[0].getDataList()[metrics[0].getDataList().length - 1].getY().toFixed(2)} {unit ? unit : ''} -
- {title ? ( -
{title}
- ) : null} - -
- - {metrics.map((metric, index) => ( - - ))} - -
-
- ); -}; - -export default Chart; diff --git a/app/src/components/datasources/Datasource.tsx b/app/src/components/datasources/Datasource.tsx deleted file mode 100644 index d84a62043..000000000 --- a/app/src/components/datasources/Datasource.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { Alert, AlertActionLink, AlertVariant, PageSection, PageSectionVariants } from '@patternfly/react-core'; -import { useHistory, useParams } from 'react-router-dom'; -import React from 'react'; - -import Elasticsearch from 'components/datasources/elasticsearch/Elasticsearch'; -import Prometheus from 'components/datasources/prometheus/Prometheus'; - -interface IDatasourceParams { - type: string; - name: string; -} - -// Datasource is the component, which checks the provided type from the URL and renders the corresponding component for -// the datasource type. -const Datasource: React.FunctionComponent = () => { - const params = useParams(); - const history = useHistory(); - - const goToDatasources = (): void => { - history.push('/'); - }; - - if (params.type === 'prometheus') { - return ; - } - - if (params.type === 'elasticsearch') { - return ; - } - - // When the provided datasource type, isn't valid, the user will see the following error, with an action to go back to - // the datasource page. - return ( - - - Datasources - - } - /> - - ); -}; - -export default Datasource; diff --git a/app/src/components/datasources/Datasources.tsx b/app/src/components/datasources/Datasources.tsx deleted file mode 100644 index 1ba3ec33e..000000000 --- a/app/src/components/datasources/Datasources.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { - Alert, - AlertActionLink, - AlertVariant, - Gallery, - GalleryItem, - PageSection, - PageSectionVariants, - Title, -} from '@patternfly/react-core'; -import React, { useCallback, useEffect, useState } from 'react'; -import { useHistory } from 'react-router-dom'; - -import { GetDatasourcesRequest, GetDatasourcesResponse } from 'generated/proto/datasources_pb'; -import { Datasource } from 'generated/proto/datasources_pb'; -import { DatasourcesPromiseClient } from 'generated/proto/datasources_grpc_web_pb'; -import Item from 'components/datasources/Item'; -import { apiURL } from 'utils/constants'; -import { datasourcesDescription } from 'utils/constants'; - -const datasourcesService = new DatasourcesPromiseClient(apiURL, null, null); - -// Datasources renders a gallery with all configured datasources. A click on the card of a datasource redirects the user -// to the page for this datasource. -const Datasources: React.FunctionComponent = () => { - const history = useHistory(); - const [datasources, setDatasources] = useState([]); - const [error, setError] = useState(''); - - // In case of an error the user can retry the API call or he can go back to the overview page. - const goToOverview = (): void => { - history.push('/'); - }; - - // fetchDatasources fetches all datasources from the gRPC API. When an error occurs during the API call, the user will - // see the error and he can retry the call. - const fetchDatasources = useCallback(async () => { - try { - const getDatasourcesRequest = new GetDatasourcesRequest(); - - const getDatasourcesResponse: GetDatasourcesResponse = await datasourcesService.getDatasources( - getDatasourcesRequest, - null, - ); - - setError(''); - setDatasources(getDatasourcesResponse.getDatasourcesList()); - } catch (err) { - setError(err.message); - } - }, []); - - useEffect(() => { - fetchDatasources(); - }, [fetchDatasources]); - - // If there is an error, we will show it to the user. The user then has the option to retry the failed API call or to - // go to the overview page. - if (error) { - return ( - - - Retry - Overview - - } - > -

{error}

-
-
- ); - } - - return ( - - - - Datasources - -

{datasourcesDescription}

-
- - - - {datasources.map((datasource, index) => ( - - - - ))} - - -
- ); -}; - -export default Datasources; diff --git a/app/src/components/datasources/Item.tsx b/app/src/components/datasources/Item.tsx deleted file mode 100644 index 64dea8bef..000000000 --- a/app/src/components/datasources/Item.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Card, CardBody, CardTitle } from '@patternfly/react-core'; -import React from 'react'; -import { useHistory } from 'react-router-dom'; - -// ILogos is the interface for the datasource logos. -interface ILogos { - [key: string]: string; -} - -// logos is an object, with the datasource types as key and the image path for that datasource as logo. -const logos: ILogos = { - elasticsearch: '/img/datasources/elasticsearch.png', - prometheus: '/img/datasources/prometheus.png', -}; - -interface IItemProps { - name: string; - type: string; - link: string; -} - -// Item is a single datasource for the gallery in the datasources page. The datasource is presented inside a Card -// component. The card contains the configured name of the datasource, a link to the corresponding datasource page and -// the brand icon for the datasource. -const Item: React.FunctionComponent = ({ name, type, link }: IItemProps) => { - const history = useHistory(); - - const handleClick = (): void => { - history.push(link); - }; - - return ( - - {name} - - {type} - - - ); -}; - -export default Item; diff --git a/app/src/components/datasources/elasticsearch/Buckets.tsx b/app/src/components/datasources/elasticsearch/Buckets.tsx deleted file mode 100644 index 8839931a5..000000000 --- a/app/src/components/datasources/elasticsearch/Buckets.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { Card, CardBody, CardTitle } from '@patternfly/react-core'; -import { - Chart, - ChartAxis, - ChartBar, - ChartLegendTooltip, - ChartThemeColor, - createContainer, -} from '@patternfly/react-charts'; -import React, { useEffect, useRef, useState } from 'react'; - -import { DatasourceLogsBucket } from 'generated/proto/datasources_pb'; -import { formatTime } from 'utils/helpers'; - -interface ILabels { - datum: DatasourceLogsBucket.AsObject; -} - -export interface IBucketsProps { - hits: number; - took: number; - buckets: DatasourceLogsBucket.AsObject[]; -} - -// Buckets renders a bar chart with the distribution of the number of logs accross the selected time range. -const Buckets: React.FunctionComponent = ({ hits, took, buckets }: IBucketsProps) => { - const refChart = useRef(null); - const [width, setWidth] = useState(0); - const [height, setHeight] = useState(0); - - // useEffect is executed on every render of this component. This is needed, so that we are able to use a width of 100% - // and a static height for the chart. - useEffect(() => { - if (refChart && refChart.current) { - setWidth(refChart.current.getBoundingClientRect().width); - setHeight(refChart.current.getBoundingClientRect().height); - } - }, []); - - const CursorVoronoiContainer = createContainer('voronoi', 'cursor'); - const legendData = [{ childName: 'count', name: 'Number of Documents' }]; - - return ( - - - {hits} Documents in {took} Milliseconds - - -
- `${datum.y}`} - labelComponent={ - formatTime(Math.floor(point.x / 1000))} - /> - } - mouseFollowTooltips - voronoiDimension="x" - voronoiPadding={0} - /> - } - height={height} - legendData={legendData} - legendPosition={undefined} - padding={{ bottom: 30, left: 0, right: 0, top: 0 }} - scale={{ x: 'time', y: 'linear' }} - themeColor={ChartThemeColor.multiOrdered} - width={width} - > - - - -
-
-
- ); -}; - -export default Buckets; diff --git a/app/src/components/datasources/elasticsearch/Document.tsx b/app/src/components/datasources/elasticsearch/Document.tsx deleted file mode 100644 index 3e5a3add6..000000000 --- a/app/src/components/datasources/elasticsearch/Document.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { - DrawerActions, - DrawerCloseButton, - DrawerHead, - DrawerPanelBody, - DrawerPanelContent, -} from '@patternfly/react-core'; -import React, { useEffect, useRef } from 'react'; -import { highlightBlock, registerLanguage } from 'highlight.js'; -import json from 'highlight.js/lib/languages/json'; - -import 'highlight.js/styles/nord.css'; - -import { IDocument, formatTimeWrapper } from 'components/datasources/elasticsearch/helpers'; -import Title from 'components/shared/Title'; - -registerLanguage('json', json); - -export interface IDocumentProps { - document: IDocument; - close: () => void; -} - -// Document renders a single document in a drawer panel. We show the whole JSON representation for this document in a -// code view. The highlighting of this JSON document is handled by highlight.js. -const Document: React.FunctionComponent = ({ document, close }: IDocumentProps) => { - const code = useRef(null); - - // useEffect apply the highlighting to the given JSON document. - useEffect(() => { - if (code.current) { - highlightBlock(code.current); - } - }); - - return ( - - - - <DrawerActions className="kobs-drawer-actions"> - <DrawerCloseButton onClose={close} /> - </DrawerActions> - </DrawerHead> - - <DrawerPanelBody className="kobs-drawer-panel-body"> - <pre className="pf-u-pb-md"> - <code ref={code}>{JSON.stringify(document, null, 2)}</code> - </pre> - </DrawerPanelBody> - </DrawerPanelContent> - ); -}; - -export default Document; diff --git a/app/src/components/datasources/elasticsearch/Documents.tsx b/app/src/components/datasources/elasticsearch/Documents.tsx deleted file mode 100644 index 820ff3f62..000000000 --- a/app/src/components/datasources/elasticsearch/Documents.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { TableComposable, TableVariant, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; -import React from 'react'; - -import { IDocument, formatTimeWrapper, getProperty } from 'components/datasources/elasticsearch/helpers'; - -export interface IDocumentsProps { - selectedFields: string[]; - documents: IDocument[]; - select?: (doc: IDocument) => void; -} - -// Documents renders a list of documents. If the user has selected some fields, we will render the table with the -// selected fields. If the selected fields list is empty, we only render the @timestamp field and the _source field as -// the only two columns -const Documents: React.FunctionComponent<IDocumentsProps> = ({ - selectedFields, - documents, - select, -}: IDocumentsProps) => { - return ( - <div className="kobsis-table-wrapper"> - <TableComposable aria-label="Logs" variant={TableVariant.compact} borders={false}> - <Thead> - <Tr> - <Th>Time</Th> - {selectedFields.length > 0 ? ( - selectedFields.map((selectedField, index) => <Th key={index}>{selectedField}</Th>) - ) : ( - <Th>_source</Th> - )} - </Tr> - </Thead> - <Tbody> - {documents.map((document, index) => ( - <Tr key={index} onClick={select ? (): void => select(document) : undefined}> - <Td dataLabel="Time">{formatTimeWrapper(document['_source']['@timestamp'])}</Td> - {selectedFields.length > 0 ? ( - selectedFields.map((selectedField, index) => ( - <Td key={index} dataLabel={selectedField}> - {getProperty(document['_source'], selectedField)} - </Td> - )) - ) : ( - <Td dataLabel="_source">{JSON.stringify(document['_source'])}</Td> - )} - </Tr> - ))} - </Tbody> - </TableComposable> - </div> - ); -}; - -export default Documents; diff --git a/app/src/components/datasources/elasticsearch/Elasticsearch.tsx b/app/src/components/datasources/elasticsearch/Elasticsearch.tsx deleted file mode 100644 index 88d858f62..000000000 --- a/app/src/components/datasources/elasticsearch/Elasticsearch.tsx +++ /dev/null @@ -1,279 +0,0 @@ -import { - Alert, - AlertVariant, - Button, - ButtonVariant, - Drawer, - DrawerContent, - DrawerContentBody, - Grid, - GridItem, - PageSection, - PageSectionVariants, - TextInput, - Title, - Toolbar, - ToolbarContent, - ToolbarGroup, - ToolbarItem, - ToolbarToggleGroup, -} from '@patternfly/react-core'; -import React, { useCallback, useEffect, useState } from 'react'; -import { useHistory, useLocation } from 'react-router-dom'; -import FilterIcon from '@patternfly/react-icons/dist/js/icons/filter-icon'; - -import { DatasourceLogsBucket, GetLogsRequest, GetLogsResponse } from 'generated/proto/datasources_pb'; -import { IDocument, getFields } from 'components/datasources/elasticsearch/helpers'; -import { ApplicationLogsQuery } from 'generated/proto/application_pb'; -import Buckets from 'components/datasources/elasticsearch/Buckets'; -import { DatasourcesPromiseClient } from 'generated/proto/datasources_grpc_web_pb'; -import Document from 'components/datasources/elasticsearch/Document'; -import Documents from 'components/datasources/elasticsearch/Documents'; -import Fields from 'components/datasources/elasticsearch/Fields'; -import { IDatasourceOptions } from 'utils/proto'; -import Options from 'components/applications/details/metrics/Options'; -import { apiURL } from 'utils/constants'; -import { convertDatasourceOptionsToProto } from 'utils/proto'; - -const datasourcesService = new DatasourcesPromiseClient(apiURL, null, null); - -// IQueryOptions is the interface for all query options. It extends the existing datasource options interface and adds -// a new query property. -interface IQueryOptions extends IDatasourceOptions { - query: string; - scrollID: string; - selectedFields: string[]; -} - -// parseSearch parses the provided query parameters and returns a query options object. This is needed so that an user -// can share his current URL with other users. So that this URL must contain all properties provided by the user. -const parseSearch = (search: string): IQueryOptions => { - const params = new URLSearchParams(search); - const fields = params.get('fields'); - - return { - query: params.get('query') ? (params.get('query') as string) : '', - scrollID: params.get('scrollID') ? (params.get('scrollID') as string) : '', - selectedFields: fields ? fields.split(',') : [], - timeEnd: params.get('timeEnd') ? parseInt(params.get('timeEnd') as string) : Math.floor(Date.now() / 1000), - timeStart: params.get('timeStart') - ? parseInt(params.get('timeStart') as string) - : Math.floor(Date.now() / 1000) - 3600, - }; -}; - -export interface IElasticsearchProps { - name: string; -} - -// Elasticsearhc implements the Elasticsearch UI for kobs. It can be used to query a configured Elasticsearch instance -// and show the logs in a table. -const Elasticsearch: React.FunctionComponent<IElasticsearchProps> = ({ name }: IElasticsearchProps) => { - const history = useHistory(); - const location = useLocation(); - const [query, setQuery] = useState<string>(''); - const [hits, setHits] = useState<number>(0); - const [took, setTook] = useState<number>(0); - const [scrollID, setScrollID] = useState<string>(''); - const [options, setOptions] = useState<IDatasourceOptions>(); - const [selectedFields, setSelectedFields] = useState<string[]>([]); - const [fields, setFields] = useState<string[]>([]); - const [documents, setDocuments] = useState<IDocument[]>([]); - const [document, setDocument] = useState<IDocument>(); - const [buckets, setBuckets] = useState<DatasourceLogsBucket.AsObject[]>([]); - const [error, setError] = useState<string>(''); - const [isLoading, setIsLoading] = useState<boolean>(false); - - // load changes the query parameters for the current page, to the user provided values. We change the query - // parameters, instead of directly fetching the logs, so that a user can share his current view with other users. - const load = async (): Promise<void> => { - history.push({ - pathname: location.pathname, - search: `?query=${query}&fields=${selectedFields.join(',')}&timeEnd=${options?.timeEnd}&timeStart=${ - options?.timeStart - }`, - }); - }; - - // loadMore is called, when the user clicks the load more button. Instead to the normal load function we set the - // scroll id as additional query parameter. - const loadMore = async (): Promise<void> => { - history.push({ - pathname: location.pathname, - search: `?query=${query}&fields=${selectedFields.join(',')}&scrollID=${scrollID}&timeEnd=${ - options?.timeEnd - }&timeStart=${options?.timeStart}`, - }); - }; - - // selectField adds the given field to the list of selected fields. - const selectField = (field: string): void => { - setSelectedFields((f) => [...f, field]); - }; - - // unselectField removes the given field from the list of selected fields. - const unselectField = (field: string): void => { - setSelectedFields(selectedFields.filter((f) => f !== field)); - }; - - // fetchLogs call the getLogs function to retrieve the logs for a given query. If the scroll id is present in the - // query options, we are fetching more logs for a query and adding the logs to the documents list. If the scroll id - // isn't present we set the documents to the result list. - // The returned logs are a string, but since we know that this is a Elasticsearch datasource, we can savely parse the - // string into a JSON array. - const fetchLogs = useCallback( - async (queryOptions: IQueryOptions): Promise<void> => { - try { - if (queryOptions.query) { - setIsLoading(true); - const logsQuery = new ApplicationLogsQuery(); - logsQuery.setQuery(queryOptions.query); - - const getLogsRequest = new GetLogsRequest(); - getLogsRequest.setName(name); - getLogsRequest.setScrollid(queryOptions.scrollID); - getLogsRequest.setOptions(convertDatasourceOptionsToProto(queryOptions)); - getLogsRequest.setQuery(logsQuery); - - const getLogsResponse: GetLogsResponse = await datasourcesService.getLogs(getLogsRequest, null); - - if (queryOptions.scrollID === '') { - const parsed = JSON.parse(getLogsResponse.getLogs()); - setFields(getFields(parsed.slice(parsed.length > 10 ? 10 : parsed.length))); - setDocuments(parsed); - } else { - setDocuments((d) => [...d, ...JSON.parse(getLogsResponse.getLogs())]); - } - - if (getLogsResponse.toObject().bucketsList.length > 0) { - setBuckets(getLogsResponse.toObject().bucketsList); - } - - setHits(getLogsResponse.getHits()); - setTook(getLogsResponse.getTook()); - setScrollID(getLogsResponse.getScrollid()); - setIsLoading(false); - setError(''); - } - } catch (err) { - setIsLoading(false); - setError(err.message); - } - }, - [name], - ); - - // useEffect is called every time, when the query parameters for the current location are changing. Then we parse the - // query parameters, setting our states to the new values and finally we are calling the fetch logs function. - useEffect(() => { - const queryOptions = parseSearch(location.search); - setQuery(queryOptions.query); - setSelectedFields(queryOptions.selectedFields); - setOptions(queryOptions); - fetchLogs(queryOptions); - }, [fetchLogs, location.search]); - - return ( - <React.Fragment> - <PageSection variant={PageSectionVariants.light}> - <Title headingLevel="h6" size="xl"> - {name} - - - - - } breakpoint="lg"> - - - setQuery(value)} - /> - - {options ? ( - - setOptions(opts)} /> - - ) : null} - - - - - - - - - - - setDocument(undefined)} /> : undefined - } - > - - - {error ? ( - -

{error}

-
- ) : ( - - - - {fields.length > 0 || selectedFields.length > 0 ? ( - - ) : null} - - - {buckets.length > 0 ? : null} - -

 

- - {documents.length > 0 ? ( - setDocument(doc)} - /> - ) : null} - -

 

- - {scrollID !== '' ? ( - - ) : null} -
-
-
- )} -
-
-
-
- - ); -}; - -export default Elasticsearch; diff --git a/app/src/components/datasources/elasticsearch/Fields.tsx b/app/src/components/datasources/elasticsearch/Fields.tsx deleted file mode 100644 index b089c8694..000000000 --- a/app/src/components/datasources/elasticsearch/Fields.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { SimpleList, SimpleListItem } from '@patternfly/react-core'; -import React from 'react'; - -export interface IFieldsProps { - fields: string[]; - selectedFields: string[]; - selectField: (field: string) => void; - unselectField: (field: string) => void; -} - -// Fields is used to show the list of parsed and selected fields. When a user selects a field from the fields list, this -// field is added to the list of selected fields. When the user selects a field from the selected fields list this field -// will be removed from this list. -const Fields: React.FunctionComponent = ({ - fields, - selectedFields, - selectField, - unselectField, -}: IFieldsProps) => { - return ( - - {selectedFields.length > 0 ?

Selected Fields

: null} - - {selectedFields.length > 0 ? ( - - {selectedFields.map((selectedField, index) => ( - unselectField(selectedField)} isActive={false}> - {selectedField} - - ))} - - ) : null} - - {fields.length > 0 ?

Fields

: null} - - {fields.length > 0 ? ( - - {fields.map((field, index) => ( - selectField(field)} isActive={false}> - {field} - - ))} - - ) : null} -
- ); -}; - -export default Fields; diff --git a/app/src/components/datasources/elasticsearch/helpers.ts b/app/src/components/datasources/elasticsearch/helpers.ts deleted file mode 100644 index 385ec39ef..000000000 --- a/app/src/components/datasources/elasticsearch/helpers.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { formatTime } from 'utils/helpers'; - -// IDocument is the interface for a single Elasticsearch document. It is just an general interface for the JSON -// representation of this document. -export interface IDocument { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [key: string]: any; -} - -// getFieldsRecursively returns the fields for a single document as a list of string. -export const getFieldsRecursively = (prefix: string, document: IDocument): string[] => { - const fields: string[] = []; - for (const field in document) { - if (typeof document[field] === 'object') { - fields.push(...getFieldsRecursively(prefix ? `${prefix}.${field}` : field, document[field])); - } else { - fields.push(prefix ? `${prefix}.${field}` : field); - } - } - - return fields; -}; - -// getFields is used to get all fields as strings for the given documents. To get the fields we are looping over the -// given documents and adding each field from this document. As a last step we have to remove all duplicated fields. -export const getFields = (documents: IDocument[]): string[] => { - const fields: string[] = []; - for (const document of documents) { - fields.push(...getFieldsRecursively('', document['_source'])); - } - - const uniqueFields: string[] = []; - for (const field of fields) { - if (uniqueFields.indexOf(field) === -1) { - uniqueFields.push(field); - } - } - - return uniqueFields; -}; - -// getProperty returns the property of an object for a given key. -// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types -export const getProperty = (obj: any, key: string): string | number => { - return key.split('.').reduce((o, x) => { - return typeof o == 'undefined' || o === null ? o : o[x]; - }, obj); -}; - -// formatTimeWrapper is a wrapper for our shared formatTime function. It is needed to convert a given time string to the -// corresponding timestamp representation, which we need for the formatTime function. -export const formatTimeWrapper = (time: string): string => { - return formatTime(Math.floor(new Date(time).getTime() / 1000)); -}; diff --git a/app/src/components/datasources/prometheus/Data.tsx b/app/src/components/datasources/prometheus/Data.tsx deleted file mode 100644 index 7e3c5b1ab..000000000 --- a/app/src/components/datasources/prometheus/Data.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { - Card, - CardBody, - Flex, - FlexItem, - SimpleList, - SimpleListItem, - ToggleGroup, - ToggleGroupItem, -} from '@patternfly/react-core'; -import React, { useState } from 'react'; - -import { DatasourceMetrics } from 'generated/proto/datasources_pb'; -import DefaultChart from 'components/applications/details/metrics/charts/Default'; - -interface IDataProps { - data: DatasourceMetrics[]; -} - -// Data is used to render the fetched time series, for a user provided query. By default the corresponding chart will -// render all loaded time series. When the user selects a specif time series, the chart will only render this series. -// A user can also decided, how he wants to see his data: As line vs. area chart or unstacked vs. stacked. -const Data: React.FunctionComponent = ({ data }: IDataProps) => { - const [type, setType] = useState('line'); - const [stacked, setStacked] = useState(false); - const [selectedData, setSelectedData] = useState([]); - - const select = (series: DatasourceMetrics): void => { - if (selectedData.length === 1 && selectedData[0].getLabel() === series.getLabel()) { - setSelectedData(data); - } else { - setSelectedData([series]); - } - }; - - if (data.length === 0) { - return null; - } - - return ( - - - - - - setType('line')} /> - setType('area')} /> - - - - - setStacked(false)} /> - setStacked(true)} /> - - - - -

 

- - - -

 

- - - {data.map((series, index) => ( - select(series)} - isActive={selectedData.length === 1 && selectedData[0].getLabel() === series.getLabel()} - > - {series.getLabel()} - {series.getDataList()[series.getDataList().length - 1].getY()} - - ))} - -
-
- ); -}; - -export default Data; diff --git a/app/src/components/datasources/prometheus/Prometheus.tsx b/app/src/components/datasources/prometheus/Prometheus.tsx deleted file mode 100644 index f37bedda5..000000000 --- a/app/src/components/datasources/prometheus/Prometheus.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { - Alert, - AlertVariant, - Button, - ButtonVariant, - PageSection, - PageSectionVariants, - TextArea, - Title, - Toolbar, - ToolbarContent, - ToolbarGroup, - ToolbarItem, - ToolbarToggleGroup, -} from '@patternfly/react-core'; -import React, { useCallback, useEffect, useState } from 'react'; -import { useHistory, useLocation } from 'react-router-dom'; -import FilterIcon from '@patternfly/react-icons/dist/js/icons/filter-icon'; - -import { DatasourceMetrics, GetMetricsRequest, GetMetricsResponse } from 'generated/proto/datasources_pb'; -import { ApplicationMetricsQuery } from 'generated/proto/application_pb'; -import Data from 'components/datasources/prometheus/Data'; -import { DatasourcesPromiseClient } from 'generated/proto/datasources_grpc_web_pb'; -import { IDatasourceOptions } from 'utils/proto'; -import Options from 'components/applications/details/metrics/Options'; -import { apiURL } from 'utils/constants'; -import { convertDatasourceOptionsToProto } from 'utils/proto'; - -const datasourcesService = new DatasourcesPromiseClient(apiURL, null, null); - -// IQueryOptions is the interface for all query options. It extends the existing datasource options interface and adds -// a new query property. -interface IQueryOptions extends IDatasourceOptions { - query: string; -} - -// parseSearch parses the provided query parameters and returns a query options object. This is needed so that an user -// can share his current URL with other users. So that this URL must contain all properties provided by the user. -const parseSearch = (search: string): IQueryOptions => { - const params = new URLSearchParams(search); - return { - query: params.get('query') ? (params.get('query') as string) : '', - resolution: params.get('resolution') ? (params.get('resolution') as string) : '', - timeEnd: params.get('timeEnd') ? parseInt(params.get('timeEnd') as string) : Math.floor(Date.now() / 1000), - timeStart: params.get('timeStart') - ? parseInt(params.get('timeStart') as string) - : Math.floor(Date.now() / 1000) - 3600, - }; -}; - -export interface IPrometheusProps { - name: string; -} - -// Prometheus implements the Prometheus UI for kobs. It can be used to query a configured Prometheus instance and show -// the results in a list and chart. -const Prometheus: React.FunctionComponent = ({ name }: IPrometheusProps) => { - const history = useHistory(); - const location = useLocation(); - const [query, setQuery] = useState(''); - const [options, setOptions] = useState(); - const [data, setData] = useState([]); - const [error, setError] = useState(''); - const [isLoading, setIsLoading] = useState(false); - - // run changes the query parameters for the current page, to the user provided values. We change the query parameters, - // instead of directly fetching the data, so that a user can share his current view with other users. - const run = async (): Promise => { - history.push({ - pathname: location.pathname, - search: `?query=${query}&resolution=${options?.resolution}&timeEnd=${options?.timeEnd}&timeStart=${options?.timeStart}`, - }); - }; - - // fetchData is used to fetch all metrics for the user provided query. We also set the user provided datasource - // options, which are used to set the time range and resolution of the returned time series data. - const fetchData = useCallback( - async (queryOptions: IQueryOptions): Promise => { - try { - if (queryOptions.query) { - setIsLoading(true); - const metricsQuery = new ApplicationMetricsQuery(); - metricsQuery.setQuery(queryOptions.query); - - const getMetricsRequest = new GetMetricsRequest(); - getMetricsRequest.setName(name); - getMetricsRequest.setOptions(convertDatasourceOptionsToProto(queryOptions)); - getMetricsRequest.setQueriesList([metricsQuery]); - - const getMetricsResponse: GetMetricsResponse = await datasourcesService.getMetrics(getMetricsRequest, null); - - setData(getMetricsResponse.getMetricsList()); - setIsLoading(false); - setError(''); - } - } catch (err) { - setIsLoading(false); - setError(err.message); - } - }, - [name], - ); - - // useEffect is executed every time the query parameters (location.search) are changing. When the parameters are - // changed we are pasing the query string, setting the corresponding query and options variable and then we are - // fetching the data. - useEffect(() => { - const queryOptions = parseSearch(location.search); - setQuery(queryOptions.query); - setOptions(queryOptions); - fetchData(queryOptions); - }, [fetchData, location.search]); - - return ( - - - - {name} - - - - - } breakpoint="lg"> - - -