From 92b297f81e96254c185946bd950cf7f1839268b9 Mon Sep 17 00:00:00 2001 From: ricoberger Date: Sat, 27 Mar 2021 16:13:26 +0100 Subject: [PATCH] Add plugin system This commit introduces a new plugin system for kobs. The new plugin system can be used to extend the functionality of kobs. Each plugin is described by his own ".proto" file. The plugin can then be registered in the "pkg/api/plugins/plugins/plugins.go" file. The frontend components must be registered in the "app/src/utils/plugins.tsx" file. Each plugin must define two React components: One component is responsible for directly using the plugin. THe other component allows the usage of the plugin within an application. We also readding Prometheus and Elasticsearch as plugins (previously known as datasources). The Prometheus plugin allows a user to define variables and charts within the Application CR, The metrics are then shown as charts under the corresponding plugin tab. Next to this the plugin also allows a user to directly query a configured Prometheus instance. The Elasticsearch plugin allows a user to define queries within the Application CR. The retrieved logs are then shown in the corresponding plugin tab. The Elasticsearch plugin also allows to directly query an Elasticsearch instance. --- .gitattributes | 1 + CHANGELOG.md | 1 + CONTRIBUTING.md | 145 + app/package.json | 9 +- .../elasticsearch.png | Bin .../img/{datasources => plugins}/jaeger.png | Bin app/public/img/plugins/kobs.png | Bin 0 -> 33006 bytes app/public/img/plugins/kubernetes.png | Bin 0 -> 72275 bytes app/public/img/plugins/plugins.png | Bin 0 -> 25274 bytes .../{datasources => plugins}/prometheus.png | Bin app/src/App.tsx | 25 +- app/src/components/Editor.tsx | 1 + app/src/components/Home.tsx | 41 +- app/src/components/HomeItem.tsx | 22 +- app/src/components/Options.tsx | 213 ++ .../components/applications/Application.tsx | 37 +- .../applications/ApplicationDetails.tsx | 31 +- .../applications/ApplicationTabs.tsx | 22 +- .../applications/ApplicationTabsContent.tsx | 104 +- app/src/components/plugins/Plugin.tsx | 47 + .../components/plugins/PluginDataMissing.tsx | 35 + app/src/components/plugins/PluginPage.tsx | 48 + app/src/context/PluginsContext.tsx | 145 + .../elasticsearch/ElasticsearchLogs.tsx | 192 ++ .../ElasticsearchLogsBuckets.tsx | 122 + .../ElasticsearchLogsBucketsAction.tsx | 51 + .../ElasticsearchLogsDocument.tsx | 46 + .../ElasticsearchLogsDocuments.tsx | 54 + .../elasticsearch/ElasticsearchLogsFields.tsx | 47 + .../elasticsearch/ElasticsearchLogsGrid.tsx | 101 + .../elasticsearch/ElasticsearchPage.tsx | 110 + .../ElasticsearchPageToolbar.tsx | 91 + .../elasticsearch/ElasticsearchPlugin.tsx | 86 + .../ElasticsearchPluginToolbar.tsx | 93 + app/src/plugins/elasticsearch/helpers.ts | 86 + .../prometheus/PrometheusChartActions.tsx | 49 + .../prometheus/PrometheusChartDefault.tsx | 108 + .../prometheus/PrometheusChartSparkline.tsx | 50 + app/src/plugins/prometheus/PrometheusPage.tsx | 141 + .../plugins/prometheus/PrometheusPageData.tsx | 92 + .../prometheus/PrometheusPageToolbar.tsx | 117 + .../plugins/prometheus/PrometheusPlugin.tsx | 128 + .../prometheus/PrometheusPluginChart.tsx | 145 + .../prometheus/PrometheusPluginCharts.tsx | 62 + .../prometheus/PrometheusPluginToolbar.tsx | 72 + .../plugins/prometheus/PrometheusVariable.tsx | 46 + app/src/plugins/prometheus/helpers.ts | 29 + app/src/proto/application_pb.d.ts | 7 + app/src/proto/application_pb.js | 59 +- app/src/proto/elasticsearch_grpc_web_pb.js | 155 + app/src/proto/elasticsearch_pb.d.ts | 157 + app/src/proto/elasticsearch_pb.js | 1214 ++++++++ app/src/proto/elasticsearch_pb_service.d.ts | 63 + app/src/proto/elasticsearch_pb_service.js | 61 + app/src/proto/plugins_grpc_web_pb.js | 158 + app/src/proto/plugins_pb.d.ts | 105 + app/src/proto/plugins_pb.js | 792 +++++ app/src/proto/plugins_pb_service.d.ts | 63 + app/src/proto/plugins_pb_service.js | 61 + app/src/proto/prometheus_grpc_web_pb.js | 235 ++ app/src/proto/prometheus_pb.d.ts | 331 +++ app/src/proto/prometheus_pb.js | 2618 +++++++++++++++++ app/src/proto/prometheus_pb_service.d.ts | 82 + app/src/proto/prometheus_pb_service.js | 101 + app/src/utils/plugins.tsx | 51 + app/yarn.lock | 2201 +++++++------- cmd/kobs/kobs.go | 31 +- deploy/docker/envoy/envoy.yaml | 2 +- deploy/docker/kobs/config.yaml | 24 + .../kustomize/crds/kobs.io_applications.yaml | 115 + go.mod | 2 + go.sum | 138 +- pkg/api/api.go | 13 +- .../application/proto/application.pb.go | 103 +- .../proto/application_deepcopy.gen.go | 1 + .../plugins/elasticsearch/elasticsearch.go | 223 ++ .../elasticsearch/proto/elasticsearch.pb.go | 532 ++++ .../proto/elasticsearch_deepcopy.gen.go | 120 + .../proto/elasticsearch_grpc.pb.go | 101 + pkg/api/plugins/plugins/plugins.go | 74 + pkg/api/plugins/plugins/proto/plugins.pb.go | 396 +++ .../plugins/proto/plugins_deepcopy.gen.go | 101 + .../plugins/plugins/proto/plugins_grpc.pb.go | 101 + .../plugins/plugins/shared/roundtripper.go | 42 + pkg/api/plugins/prometheus/helpers.go | 66 + pkg/api/plugins/prometheus/prometheus.go | 287 ++ .../plugins/prometheus/proto/prometheus.pb.go | 1031 +++++++ .../proto/prometheus_deepcopy.gen.go | 225 ++ .../prometheus/proto/prometheus_grpc.pb.go | 137 + pkg/config/config.go | 37 + proto/application.proto | 3 + proto/elasticsearch.proto | 51 + proto/plugins.proto | 38 + proto/prometheus.proto | 104 + 94 files changed, 14383 insertions(+), 1274 deletions(-) create mode 100644 .gitattributes rename app/public/img/{datasources => plugins}/elasticsearch.png (100%) rename app/public/img/{datasources => plugins}/jaeger.png (100%) create mode 100644 app/public/img/plugins/kobs.png create mode 100644 app/public/img/plugins/kubernetes.png create mode 100644 app/public/img/plugins/plugins.png rename app/public/img/{datasources => plugins}/prometheus.png (100%) create mode 100644 app/src/components/Options.tsx create mode 100644 app/src/components/plugins/Plugin.tsx create mode 100644 app/src/components/plugins/PluginDataMissing.tsx create mode 100644 app/src/components/plugins/PluginPage.tsx create mode 100644 app/src/context/PluginsContext.tsx create mode 100644 app/src/plugins/elasticsearch/ElasticsearchLogs.tsx create mode 100644 app/src/plugins/elasticsearch/ElasticsearchLogsBuckets.tsx create mode 100644 app/src/plugins/elasticsearch/ElasticsearchLogsBucketsAction.tsx create mode 100644 app/src/plugins/elasticsearch/ElasticsearchLogsDocument.tsx create mode 100644 app/src/plugins/elasticsearch/ElasticsearchLogsDocuments.tsx create mode 100644 app/src/plugins/elasticsearch/ElasticsearchLogsFields.tsx create mode 100644 app/src/plugins/elasticsearch/ElasticsearchLogsGrid.tsx create mode 100644 app/src/plugins/elasticsearch/ElasticsearchPage.tsx create mode 100644 app/src/plugins/elasticsearch/ElasticsearchPageToolbar.tsx create mode 100644 app/src/plugins/elasticsearch/ElasticsearchPlugin.tsx create mode 100644 app/src/plugins/elasticsearch/ElasticsearchPluginToolbar.tsx create mode 100644 app/src/plugins/elasticsearch/helpers.ts create mode 100644 app/src/plugins/prometheus/PrometheusChartActions.tsx create mode 100644 app/src/plugins/prometheus/PrometheusChartDefault.tsx create mode 100644 app/src/plugins/prometheus/PrometheusChartSparkline.tsx create mode 100644 app/src/plugins/prometheus/PrometheusPage.tsx create mode 100644 app/src/plugins/prometheus/PrometheusPageData.tsx create mode 100644 app/src/plugins/prometheus/PrometheusPageToolbar.tsx create mode 100644 app/src/plugins/prometheus/PrometheusPlugin.tsx create mode 100644 app/src/plugins/prometheus/PrometheusPluginChart.tsx create mode 100644 app/src/plugins/prometheus/PrometheusPluginCharts.tsx create mode 100644 app/src/plugins/prometheus/PrometheusPluginToolbar.tsx create mode 100644 app/src/plugins/prometheus/PrometheusVariable.tsx create mode 100644 app/src/plugins/prometheus/helpers.ts create mode 100644 app/src/proto/elasticsearch_grpc_web_pb.js create mode 100644 app/src/proto/elasticsearch_pb.d.ts create mode 100644 app/src/proto/elasticsearch_pb.js create mode 100644 app/src/proto/elasticsearch_pb_service.d.ts create mode 100644 app/src/proto/elasticsearch_pb_service.js create mode 100644 app/src/proto/plugins_grpc_web_pb.js create mode 100644 app/src/proto/plugins_pb.d.ts create mode 100644 app/src/proto/plugins_pb.js create mode 100644 app/src/proto/plugins_pb_service.d.ts create mode 100644 app/src/proto/plugins_pb_service.js create mode 100644 app/src/proto/prometheus_grpc_web_pb.js create mode 100644 app/src/proto/prometheus_pb.d.ts create mode 100644 app/src/proto/prometheus_pb.js create mode 100644 app/src/proto/prometheus_pb_service.d.ts create mode 100644 app/src/proto/prometheus_pb_service.js create mode 100644 app/src/utils/plugins.tsx create mode 100644 pkg/api/plugins/elasticsearch/elasticsearch.go create mode 100644 pkg/api/plugins/elasticsearch/proto/elasticsearch.pb.go create mode 100644 pkg/api/plugins/elasticsearch/proto/elasticsearch_deepcopy.gen.go create mode 100644 pkg/api/plugins/elasticsearch/proto/elasticsearch_grpc.pb.go create mode 100644 pkg/api/plugins/plugins/plugins.go create mode 100644 pkg/api/plugins/plugins/proto/plugins.pb.go create mode 100644 pkg/api/plugins/plugins/proto/plugins_deepcopy.gen.go create mode 100644 pkg/api/plugins/plugins/proto/plugins_grpc.pb.go create mode 100644 pkg/api/plugins/plugins/shared/roundtripper.go create mode 100644 pkg/api/plugins/prometheus/helpers.go create mode 100644 pkg/api/plugins/prometheus/prometheus.go create mode 100644 pkg/api/plugins/prometheus/proto/prometheus.pb.go create mode 100644 pkg/api/plugins/prometheus/proto/prometheus_deepcopy.gen.go create mode 100644 pkg/api/plugins/prometheus/proto/prometheus_grpc.pb.go create mode 100644 pkg/config/config.go create mode 100644 proto/elasticsearch.proto create mode 100644 proto/plugins.proto create mode 100644 proto/prometheus.proto diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..cff037807 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +app/src/proto/* linguist-vendored diff --git a/CHANGELOG.md b/CHANGELOG.md index 450f0f2a2..1a84593e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ NOTE: As semantic versioning states all 0.y.z releases can contain breaking chan - [#6](https://github.com/kobsio/kobs/pull/6): Add Prometheus as datasource for Application metrics. - [#8](https://github.com/kobsio/kobs/pull/8): Add new page to directly query a configured Prometheus datasource. - [#10](https://github.com/kobsio/kobs/pull/10): Add Elasticsearch as datasource for Application logs. +- [#12](https://github.com/kobsio/kobs/pull/12): :warning: *Breaking change:* :warning: Add plugin system and readd Prometheus and Elasticsearch as plugins. ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5c02a42d0..7ec56c18b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,6 +9,7 @@ - [Server](#server) - [Envoy](#envoy) - [Run kobs](#run-kobs) +- [Add a new Plugin](#add-a-new-plugin) Every contribution to kobs is welcome, whether it is reporting a bug, submitting a fix, proposing new features or becoming a maintainer. To make contributing to kubenav as easy as possible you will find more details for the development flow in this documentation. @@ -151,3 +152,147 @@ docker run -it --rm --name envoy -p 15222:15222 -v $(pwd)/deploy/docker/envoy/en ``` When you want to run kobs inside your Kubernetes cluster, please checkout the Documentation at [kobs.io](https://kobs.io). + +## Add a new Plugin + +To add a new plugin to kobs, you have to create a `proto/.proto`. Our [Makefile](./Makefile) will the handle the code generation for your plugin. + +```protobuf +syntax = "proto3"; +package plugins.; + +option go_package = "github.com/kobsio/kobs/pkg/api/plugins//proto"; +``` + +To add your plugin to the Application CRD, add a corresponding field to the `Plugin` message format in the `proto/plugins.proto` file: + +```protobuf +syntax = "proto3"; +package plugins; + +option go_package = "github.com/kobsio/kobs/pkg/api/plugins/plugins/proto"; + +import ".proto"; + +message Plugin { + .Spec = 1; +} +``` + +Besides the protocol buffers definition your also have to create a `pkg/api/plugins//.go` file, which implements your definition and handles the registration of your plugin. To register your plugin you have to modify the `Register` function in the `pkg/api/plugins/plugins/plugins.go` file: + +```go +package plugins + +import ( + "github.com/kobsio/kobs/pkg/api/plugins/" +) + +func Register(cfg *config.Config, grpcServer *grpc.Server) error { + Instances, err := .Register(cfg., grpcServer) + if err != nil { + log.WithError(err).WithFields(logrus.Fields{"plugin": ""}).Errorf("Failed to register plugin.") + return err + } + + plugins = append(plugins, Instances...) +} +``` + +The configuration for your plugin must be added to the `Config` struct in the `pkg/config/config.go` file: + +```go +package config + +import ( + "github.com/kobsio/kobs/pkg/api/plugins/" +) + +type Config struct { + [].Config `yaml:""` +} +``` + +Now your plugin is registered at the gRPC server and can be configured via a `config.yaml` file. In the next step you can implement the Reac UI components for your plugin. Your plugin must provide the following two components as entry point: `app/src/plugins//Page.tsx` and `app/src/plugins//Plugin.tsx`: + +```tsx +import { + PageSection, + PageSectionVariants, + Title, +} from '@patternfly/react-core'; +import React from 'react'; + +import { IPluginPageProps } from 'utils/plugins'; + +const Page: React.FunctionComponent = ({ name, description }: IPluginPageProps) => { + return ( + + + + {name} + +

{description}

+
+
+ ); +}; + +export default Page; +``` + +```tsx +import React from 'react'; +import ListIcon from '@patternfly/react-icons/dist/js/icons/list-icon'; + +import { IPluginProps } from 'utils/plugins'; +import PluginDataMissing from 'components/plugins/PluginDataMissing'; + +const Plugin: React.FunctionComponent = ({ + isInDrawer, + name, + description, + plugin, + showDetails, +}: IPluginProps) => { + if (!plugin.) { + return ( + + ); + } + + return ( + + + ); +}; + +export default Plugin; +``` + +In the last step you have to register these two React components in the `app/src/utils/plugins.tsx` file: + +```tsx +import React from 'react'; + +import Page from 'plugins//Page'; +import Plugin from 'plugins//Plugin'; + +export const plugins: IPlugins = { + : { + page: Page, + plugin: Plugin, + }, +}; +``` + +Thats it, now you can generate the Go and TypeScript code from your `.proto` file and the new Application CRD with the following command: + +```sh +make generate +``` diff --git a/app/package.json b/app/package.json index 0959d7677..40eae5978 100644 --- a/app/package.json +++ b/app/package.json @@ -15,10 +15,9 @@ ], "extends": [ "react-app", + "prettier", "plugin:react/recommended", "plugin:@typescript-eslint/recommended", - "prettier", - "prettier/@typescript-eslint", "plugin:prettier/recommended" ], "rules": { @@ -140,13 +139,13 @@ ] }, "dependencies": { - "@kubernetes/client-node": "^0.13.2", + "@kubernetes/client-node": "^0.14.0", "@patternfly/patternfly": "^4.80.3", "@patternfly/react-charts": "^6.14.2", "@patternfly/react-core": "^4.90.2", "@patternfly/react-table": "^4.20.15", "@types/google-protobuf": "^3.7.4", - "@types/node": "^12.0.0", + "@types/node": "^14.14.35", "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", "@types/react-router-dom": "^5.1.7", @@ -167,7 +166,7 @@ "@typescript-eslint/parser": "^4.15.1", "babel-eslint": "^10.1.0", "eslint": "^7.20.0", - "eslint-config-prettier": "^7.2.0", + "eslint-config-prettier": "^8.1.0", "eslint-config-react-app": "^6.0.0", "eslint-plugin-flowtype": "^5.2.0", "eslint-plugin-import": "^2.22.1", diff --git a/app/public/img/datasources/elasticsearch.png b/app/public/img/plugins/elasticsearch.png similarity index 100% rename from app/public/img/datasources/elasticsearch.png rename to app/public/img/plugins/elasticsearch.png diff --git a/app/public/img/datasources/jaeger.png b/app/public/img/plugins/jaeger.png similarity index 100% rename from app/public/img/datasources/jaeger.png rename to app/public/img/plugins/jaeger.png diff --git a/app/public/img/plugins/kobs.png b/app/public/img/plugins/kobs.png new file mode 100644 index 0000000000000000000000000000000000000000..3a6ff484cc41f7dc549443e5a7f393232f07bc8c GIT binary patch literal 33006 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 literal 0 HcmV?d00001 diff --git a/app/public/img/plugins/kubernetes.png b/app/public/img/plugins/kubernetes.png new file mode 100644 index 0000000000000000000000000000000000000000..094b413da488255835430233544c8fbf685d0ecd GIT binary patch literal 72275 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 z≷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 literal 0 HcmV?d00001 diff --git a/app/public/img/plugins/plugins.png b/app/public/img/plugins/plugins.png new file mode 100644 index 0000000000000000000000000000000000000000..137c36f3c3df6484a0a21997fe24e56baa530942 GIT binary patch literal 25274 zcmdSBdpy+J|3A70p`D%DDU?!DvI$YHF_lzyF6Az_k&s+-sR%P|yX+7<3Pp`Bh!MIG z#Y|g~+`3IjE=6R>{W@mmeO~Vw?fvAk_WT=75m(I12%f-JT&H!9GEuK7j#iah~pn1H(cM6%?R-?L2eOQ$GK44+xnz zAAnHDThzCxX{i4vd8m)q|9~8C`7h)_M*~9xLyiUp{bRs?BjTUt|I!iQ`|o!-s-{|y1K!{Cvkf*wpXGmaJu!pC~3D1Dg^?W37T=Wn4 zobvQ@*zM!*84v>K8Y*nj(A@feYIgh|HX8@}1qNF~V0l7EelHw7eN%sTZ%=!lBcVtC zgWta`W}X4w|FsF%&@T*kv2B14*rfR{KZk$Y3>9=VA-HhI^BFq+cboq4hdy6J^S~pZ zpccQ^zfGpb#s`7}y?p$jEyQZ?uC=Bn#@bu-w6!;DstJq%=jlUVAFuG;?x8@XsiC=L zvxe4YO)VSEE&5w@^tH5AG`8q#2=w7o!=oNa>J=k-nH#pi* zVW*du55O6|_5dEiYqzNB&D-`*hm%J=f!Y7qH1&DK)bWA$L$mGb|3&xhe1iWNsnAbQ z2N?iTiTXde1U~-BMxFtXf`TE}^i&#@Aw)Ri177(r-au&6#juN=DlrFTYKUz|5AU*b ze(bQ){??5ZI?w% z{-i_wsdwHV@}q{_e1B66TcG;$-?!rOm!DNS8>3>v?D2QcO+LTj56iQLSDg>ce0blz zA$p6R%NO;ONBd6672N&1Fm!nh&7tUb+3HPL|xp<*^W+*&riv;mj(CZs(E6uSQi4bZyc$9vw`m9yct2wz&<0Iykte;LyXW2KFs`8NJSMSO{ zpT7GACXIzb`y4|MTC@!RkASXb${@5BneN_cb1HMN{p3S`Uzbj7`ul{d$=3+wpA>FV ze%_y;a3d~j-3p1DN2Q$PI0+OHzdtW8`T5xHi%VX({++$>+s`|9FFClI&~K9e`$Y6C z_nvOYiAE#u;cd-VzfZ30);c0Fnd#l6C)qU49kj*gy8j>lNgR53PZ}YK-eoi=8|uOK zJLRkw!I$H?eJXatw2s-)rvrzv*=tN1jm|ygTG~uSs6}=GLiO)9)7l=%P6dvRI!B3- zo1WxLmnsXpIveIjxKG|&&2a%piX+l!L(be?8J(}le$P1`lXcAYwx?Hduv-2O%g1aC z??nW5FXk$}63frE>vW9zJ3vhs8dcx<=hxWF`*8@E#%$1}lYa-lk6eu-v)Pv1F%!dC z623Ex%E}}ZEd}!W58G(ip^AH>u~RNRcbQ4H&8t|y6A)r#I@2O{PYzYfzn#7~ZJT6< z6El7*@E@_q7bV5N`pR>b107Pm`pk{r_PgwSMf(4firnQUzy!r;&R816;iZzpBUF1S zoU>fqGjhj4Tw2iD80`K{aVT~DBEhRmRDcrcs|0T#j$iVGlA!Lh1TT1WlMpnosP0l1 zGr<+p2oWXgnejPld5om_^ZBIf#?U2XanX$DG91q|zL$tKFcNvo~9SSkAc@n znzDeAMhx9d4_A`1S(f8z!S5rN{LHOaj?c&x7@6`bo#URlrSdC_BSguL);J>u3z{4F z&GFtN(q)@)Mx^5sJOw4b?&*dx^p|;i`94W}=GOC#PnrUxvT8sodh%n-B1D=hv^C5s zCx>NUY!=!-&SRzJQ}1+aA3KE7^&qbHdXB_$p(?Mhz_I-$X5kMj503A@5k^?D-CiUmEe`Vna|rhd8V3PqFICwmvlwkaA`ZY*5LLmc9|3*c!pmsf>+b? zD)aU4hRyuJI>?+`g?+5!BSMlACAVsmo!TEvO7PN7RIt3;B*Hv87YTG5Jd5iVPb>1N zh{3X5O423B$30^Mi zZ_VqlkTzQ`O`Z0`&|2;J48N8@h285;dlZmUV@t)c&BoG4Cd^d$L6u_Ucd$g@s+NOBTX)-e4ww?`z?+soL{V_gCi)7L z+4wiE%x3>+W4{r%*gD%zX7A=T9>mjv);)aGK4=dwrSB?uqsQdZnss;&HVH&p@H@8n;sU!Q%x=Ga~u;n_{B6DVYH@tNDXmtwTj3)&1Glv_@Y zRg((^-pFLw(&U@E)T?@%Xk=->;RMzyTG}>V$ma1^bNSS}g|dk8>e`21&yqkNj`3Ah zoj7zZc+cb{bN?!=EH(V=cmK+Wu>^$dzJ=t|k8+H!6eu<24K48-o?!XX=z*NRL%o+F z$aN9r@?(oh73SFsWm%@=Z0-;k?{NvR#ykM#2+Y^`p2N z7xzA#^;?K2t&f8z=Pr={`lWRvSR0zgn!SKI*G)i9V-Zk2!+f0SaKB)akj&d_W6SRZ zk3GF#?upQ{6SiG`$#+ z#0uRi?v6?tXj-ThMCX>9b`z zeZ}mssh9TBYW-gRl|~jzi|aoTA^qyoB5b?my^fp=|1D$q7wS(YjAsW$G;UkoUE>yY zdz9)gs}`7FD1#X5+UcW`n)frd-(_=PjUf=dk=bn}qN*(U6Y5%0_`0`L+(Z8Gk(TWh z8uvWV#J@=MMKqDIi>UIFDx6|UZz4Cq0LWn~P8EeDz`daS}obE2N;~v0) z`5jQ~OZg~qDULGR!}T}XzW{YTt}GvwR1Kclo?Fg6KGb(;lhESE1x&i$mq%y!&pl`L z(Ig=+ZdPj2k;k-!(0lo9Ja-cd!=}4KL7xoDEaE6JAv?i;;|rO$K5|mSAtDy@PD-L6 z+2&WVh7B8ka<{xqPZ!5CtZ!NS z4z+F;Q__Cek8@h*h4btCl^cFP?o!0Q0+TQ1SGQq2OL%A%Dz<9!=NQMy5y`gglg~>N zQx1s8WiGN3(a0RIA=*~t0%WZ{8^k)cAW~Ss#~CrHGCy8<`RTBa=L1*AW`Vz?Z;O+3 zn+w+*tat*nd2Mo$PY`9>?B{A?=1Y_CgJDS$yeGkvti4tunVZO06)2x!D{*SFu$o-b zoVoYf;2r`R7`+L^`?&rYMTfV}49CL2;P_0w*jrg1WZHZudMnmZ{c6A>Iv&_Qw!#x( zW`Q2ro;xSO^Ee2~63v$~f)Xp1*OZ115}S!<=Y)~!CpzT*+C$^hO!6HIVypRKbr`&T zmeybv%~q1u@n)uJ>d@Hb++Ytx@tjk?K;Go&mDG4idCnNa;jebGM>+>l;x!ThNqk> zII{QH7=Niy@kivl&uTo#VojrZy=YhXD~i5Z85YZ71E15s|1y`XJh zrkn2ISuvE_z3>8A#&MUcQ<3{pcx*QoAmL{43V5ys;N0TQuBL=xT>mk;Rs#I{_zqXp7Q9Ehbkp z2XIw_dHr{!Wl%c(hk;UQGlZO7T!9T@@HE7E}&J)*-#-Xlr z0}l}>t@;(^#h{IKV+IZT)N7neasp)_v*PKRT1~#5n@L+e%MwBl!WycHf0!An->ob( zcN8*)Py^TPJx*guu7Yj6aihVPZKB9)aN`BC_?7*x$$-ByjQ4leB0<5hjUW~sH82YZ zxvlaus{h!xmACFlz(JAXMa}tZE-G~G4v&~z0P(HL+??Op&_%xVi6`E?3Z<8+`*Dn4 zI)gUS{K;kqvw_z6s*7$u6#R|07}Z}FBF$|QYFWU~w*~JS@|A4l{W;!2LkD3`_LDL? z7q38Zd2BbijCpz`CQCic(=}nNj660uAj^qOfR(-7K7vN16k+gpe25*NoCOuknCAI4 zrL!IEd(?Z`Ca^VCxs6&}T&<#_AQfff`IzGQ_~7JY7{&7(ivLxi{#`h;$WA_h<9)P$ zUWF-X6Z9nLJ4?wL#=GVIr5a*=c1g8e^d{KFks;4dK(oA1fIXnhmL`J3XpS0PhcR}_s7-;n!3 zVT4oQLH?c<0z<92yQS3fRATS4c?QuxnlDKaYC;-VOJ_gU>iv)#2y*`pYkT^K6MsBu z7alZx8ga6_bD7?uc>`u)8KQM>x$=rPF;n&vYq9P zyK=v>5G1$T5vRM|tXqE|`Sph)&hZo#d&xpx*=W&?LG7OqzUya1zzPnJ&GWSTo&DSIFREV87G@B zuA5s9YNp%i{jhd?nCkbk@K!B#2i%6y(>zG3vY>Bl@ z;BElUC?FSu=MZTUq?s`r5E$GwlsaB77zw>>pm$Y7I_E#0Bw zDLa)_a6GN5-Z^rF{OqMKLFO`KijrI6E!Qjy*0@yMWS2L9Csi7lErmTkJT*lUGPTZ#6gCfq^4Giom%iTW? z>Dof0A)ewNUTX`5yj>c89;3BA?ZJ-CqcnI0Q0o2sDaTsGIj

T~Xc;%W?5|{z!ue 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%0AFvjCwXUmc-W35J7%>bI=(*%>d}zC<_YLLsuD{l! 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 { return ( - - - - - - - - - - + + + + + + + + + + + + + ); }; diff --git a/app/src/components/Editor.tsx b/app/src/components/Editor.tsx index e4f6b2bb6..5cc2de9d0 100644 --- a/app/src/components/Editor.tsx +++ b/app/src/components/Editor.tsx @@ -1,6 +1,7 @@ import React, { useRef } from 'react'; import AceEditor from 'react-ace'; +import 'ace-builds/src-noconflict/mode-json'; import 'ace-builds/src-noconflict/mode-yaml'; import 'ace-builds/src-noconflict/theme-nord_dark'; diff --git a/app/src/components/Home.tsx b/app/src/components/Home.tsx index 611ac6176..b8b6c8337 100644 --- a/app/src/components/Home.tsx +++ b/app/src/components/Home.tsx @@ -1,6 +1,7 @@ import { Gallery, GalleryItem, PageSection, PageSectionVariants } from '@patternfly/react-core'; -import React from 'react'; +import React, { useContext } from 'react'; +import { IPluginsContext, PluginsContext } from 'context/PluginsContext'; import { applicationsDescription, resourcesDescription } from 'utils/constants'; import HomeItem from 'components/HomeItem'; @@ -9,15 +10,49 @@ import HomeItem from 'components/HomeItem'; // 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 = () => { + const pluginsContext = useContext(PluginsContext); + return ( - + - + + + {pluginsContext.plugins.length === 0 ? ( + + + + ) : ( + pluginsContext.plugins.map((plugin, index) => ( + + + + )) + )} ); diff --git a/app/src/components/HomeItem.tsx b/app/src/components/HomeItem.tsx index d5b93d587..76b4fdd82 100644 --- a/app/src/components/HomeItem.tsx +++ b/app/src/components/HomeItem.tsx @@ -1,26 +1,34 @@ -import { Card, CardBody, CardTitle } from '@patternfly/react-core'; +import { Card, CardBody, CardHeader, 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. +// IHomeItemProps is the interface for an item on the home page. Each item contains a title, body, link and icon. interface IHomeItemProps { body: string; link: string; title: string; + icon: 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) => { +// HomeItem is used to render an item in the home page. It requires a title, body, link and icon. When the card is +// clicked, the user is redirected to the provided link. +const HomeItem: React.FunctionComponent = ({ body, link, title, icon }: IHomeItemProps) => { const history = useHistory(); const handleClick = (): void => { - history.push(link); + if (link.startsWith('http')) { + window.open(link, '_blank'); + } else { + history.push(link); + } }; return ( - {title} + + {title} + {title} + {body} ); diff --git a/app/src/components/Options.tsx b/app/src/components/Options.tsx new file mode 100644 index 000000000..f3b2c6923 --- /dev/null +++ b/app/src/components/Options.tsx @@ -0,0 +1,213 @@ +import { + Button, + ButtonVariant, + Form, + FormGroup, + Level, + LevelItem, + Modal, + ModalVariant, + SimpleList, + SimpleListItem, + TextInput, +} from '@patternfly/react-core'; +import React, { useEffect, useState } from 'react'; + +import { formatTime } from 'utils/helpers'; + +// IAdditionalFields is the interface for an additional field. Each field must define a label, name, placeholder and +// value. +export interface IAdditionalFields { + label: string; + name: string; + placeholder: string; + value: string; +} + +// IOptionsProps is the interface for the properties of the Options compoennt. +interface IOptionsProps { + pAdditionalFields?: IAdditionalFields[]; + pTimeEnd: number; + pTimeStart: number; + setValues: (additionalFields: IAdditionalFields[] | undefined, timeEnd: number, timeStart: number) => void; +} + +// Options is a shared component, which can be used by plugins. It should provide the same interface to select a time +// range and can be extended with additional fields, which are unique for a plugin. +const Options: React.FunctionComponent = ({ + pAdditionalFields, + pTimeEnd, + pTimeStart, + setValues, +}: IOptionsProps) => { + const [show, setShow] = useState(false); + const [additionalFields, setAdditionalFields] = useState(pAdditionalFields); + const [timeEnd, setTimeEnd] = useState(formatTime(pTimeEnd)); + const [timeStart, setTimeStart] = useState(formatTime(pTimeStart)); + const [timeEndError, setTimeEndError] = useState(''); + const [timeStartError, setTimeStartError] = useState(''); + + // 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(''); + setValues( + additionalFields, + Math.floor(parsedTimeEnd.getTime() / 1000), + 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 => { + setValues(additionalFields, Math.floor(Date.now() / 1000), Math.floor(Date.now() / 1000) - seconds); + setShow(false); + }; + + // changeAdditionalField changes one of the given addtional fields. + const changeAdditionalField = (index: number, value: string): void => { + if (additionalFields && additionalFields.length > 0) { + const tmpAdditionalField = [...additionalFields]; + tmpAdditionalField[index].value = value; + setAdditionalFields(tmpAdditionalField); + } + }; + + // useEffect is used to update the UI, every time a property changes. + useEffect(() => { + setAdditionalFields(pAdditionalFields); + setTimeEnd(formatTime(pTimeEnd)); + setTimeStart(formatTime(pTimeStart)); + }, [pAdditionalFields, pTimeEnd, pTimeStart]); + + 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 + + + {additionalFields && additionalFields.length > 0 ? ( + +
+ {additionalFields.map((field, index) => ( + + changeAdditionalField(index, value)} + /> + + ))} +
+
+ ) : null} + + + + ); +}; + +export default Options; diff --git a/app/src/components/applications/Application.tsx b/app/src/components/applications/Application.tsx index bd9f5100e..eac4f73cb 100644 --- a/app/src/components/applications/Application.tsx +++ b/app/src/components/applications/Application.tsx @@ -9,12 +9,12 @@ import { PageSectionVariants, Spinner, } from '@patternfly/react-core'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { createRef, useCallback, useEffect, useRef, useState } from 'react'; import { useHistory, useParams } from 'react-router-dom'; +import ApplicationTabsContent, { IMountedTabs } from 'components/applications/ApplicationTabsContent'; 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'; @@ -30,6 +30,7 @@ interface IApplicationsParams { namespace: string; name: string; } + // clustersService is the Clusters gRPC service, which is used to get an application. const clustersService = new ClustersPromiseClient(apiURL, null, null); @@ -39,8 +40,21 @@ const Application: React.FunctionComponent = () => { const [data, setData] = useState({ application: undefined, error: '', isLoading: true }); const [activeTab, setActiveTab] = useState('resources'); + const [mountedTabs, setMountedTabs] = useState({}); const refResourcesContent = useRef(null); + const [refPluginsContent, setRefPluginsContent] = useState[] | undefined>( + data.application ? data.application.pluginsList.map(() => createRef()) : undefined, + ); + // changeActiveTab sets the active tab and adds the name of the selected tab to the mountedTabs object. This object is + // used to only load data, when a component is mounted the first time. + const changeActiveTab = (tab: string): void => { + setActiveTab(tab); + setMountedTabs({ ...mountedTabs, [tab]: true }); + }; + + // fetchApplication fetches a single application by the provided cluster, namespace and name. These properties are + // provided via parameters in the current URL. const fetchApplication = useCallback(async () => { try { setData({ application: undefined, error: '', isLoading: true }); @@ -65,6 +79,14 @@ const Application: React.FunctionComponent = () => { fetchApplication(); }, [fetchApplication]); + // Since the application isn't defined on the first rendering of this component, we have to create the references for + // the plugin tabs each time the application is updated. + useEffect(() => { + if (data.application) { + setRefPluginsContent(data.application.pluginsList.map(() => createRef())); + } + }, [data.application]); + if (data.isLoading) { return ; } @@ -109,14 +131,23 @@ const Application: React.FunctionComponent = () => { ) : null} - + + ); diff --git a/app/src/components/applications/ApplicationDetails.tsx b/app/src/components/applications/ApplicationDetails.tsx index 1f8063b79..2619f5f8c 100644 --- a/app/src/components/applications/ApplicationDetails.tsx +++ b/app/src/components/applications/ApplicationDetails.tsx @@ -8,12 +8,12 @@ import { ListItem, ListVariant, } from '@patternfly/react-core'; -import React, { useRef, useState } from 'react'; +import React, { createRef, useEffect, useRef, useState } from 'react'; +import ApplicationTabsContent, { IMountedTabs } from 'components/applications/ApplicationTabsContent'; 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 { @@ -27,7 +27,22 @@ const ApplicationDetails: React.FunctionComponent = ({ close, }: IApplicationDetailsProps) => { const [activeTab, setActiveTab] = useState('resources'); + const [mountedTabs, setMountedTabs] = useState({}); const refResourcesContent = useRef(null); + const [refPluginsContent, setRefPluginsContent] = useState[]>( + application.pluginsList.map(() => createRef()), + ); + + // changeActiveTab sets the active tab and adds the name of the selected tab to the mountedTabs object. This object is + // used to only load data, when a component is mounted the first time. + const changeActiveTab = (tab: string): void => { + setActiveTab(tab); + setMountedTabs({ ...mountedTabs, [tab]: true }); + }; + + useEffect(() => { + setRefPluginsContent(application.pluginsList.map(() => createRef())); + }, [application.pluginsList]); return ( @@ -57,12 +72,22 @@ const ApplicationDetails: React.FunctionComponent = ({ ) : null} - + + + diff --git a/app/src/components/applications/ApplicationTabs.tsx b/app/src/components/applications/ApplicationTabs.tsx index 6382c831d..a4e542ea1 100644 --- a/app/src/components/applications/ApplicationTabs.tsx +++ b/app/src/components/applications/ApplicationTabs.tsx @@ -1,19 +1,25 @@ import { Tab, TabTitleText, Tabs } from '@patternfly/react-core'; import React from 'react'; -interface IApplicationTabsParams { +import { Plugin } from 'proto/plugins_pb'; + +interface IApplicationTabsProps { activeTab: string; setTab(tab: string): void; + plugins: Plugin.AsObject[]; refResourcesContent: React.RefObject; + refPluginsContent: React.RefObject[] | undefined; } // 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 = ({ +const ApplicationTabs: React.FunctionComponent = ({ activeTab, setTab, + plugins, refResourcesContent, -}: IApplicationTabsParams) => { + refPluginsContent, +}: IApplicationTabsProps) => { return ( = ({ tabContentId="refResources" tabContentRef={refResourcesContent} /> + + {plugins.map((plugin, index) => ( + {plugin.name}} + tabContentId={`refPlugin-${index}`} + tabContentRef={refPluginsContent ? refPluginsContent[index] : undefined} + /> + ))} ); }; diff --git a/app/src/components/applications/ApplicationTabsContent.tsx b/app/src/components/applications/ApplicationTabsContent.tsx index 3267ceee0..f6a47b5ae 100644 --- a/app/src/components/applications/ApplicationTabsContent.tsx +++ b/app/src/components/applications/ApplicationTabsContent.tsx @@ -10,14 +10,24 @@ import React, { useState } from 'react'; import { IRow } from '@patternfly/react-table'; import { Application } from 'proto/application_pb'; +import Plugin from 'components/plugins/Plugin'; import ResourceDetails from 'components/resources/ResourceDetails'; import ResourcesList from 'components/resources/ResourcesList'; +// IMountedTabs is the interface, which is used in an object, which represents all mounted tabs. With this we can +// implement the "mountOnEnter" function from Patternfly for our tabs setup, because this function doesn't work when, +// the TabsContent component is used outside of the Tabs component. +export interface IMountedTabs { + [key: string]: boolean; +} + interface IApplicationTabsContent { application: Application.AsObject; activeTab: string; + mountedTabs: IMountedTabs; isInDrawer: boolean; refResourcesContent: React.RefObject; + refPluginsContent: React.RefObject[] | undefined; } // ApplicationTabsContent renders the content of an tab. If the component isn't rendered inside a drawer it provides a @@ -25,44 +35,78 @@ interface IApplicationTabsContent { const ApplicationTabsContent: React.FunctionComponent = ({ application, activeTab, + mountedTabs, isInDrawer, refResourcesContent, + refPluginsContent, }: IApplicationTabsContent) => { const [panelContent, setPanelContent] = useState(undefined); + const pageSection = ( + + + + isInDrawer + ? setPanelContent(undefined) + : setPanelContent( setPanelContent(undefined)} />) + } + /> + + + {application.pluginsList.map((plugin, index) => ( + + ))} + + ); + + // When the pageSection component is rendered within a drawer, we do not add the additional drawer for the resources + // and plugins, to avoid the ugly scrolling behavior for the drawer in drawer setup. + if (isInDrawer) { + return pageSection; + } + + // The pageSection isn't rendered within a drawer, so that we add one. This allows a user to show some additional data + // within the drawer panel. return ( - - - - - isInDrawer - ? setPanelContent(undefined) - : setPanelContent( - setPanelContent(undefined)} />, - ) - } - /> - - - + {pageSection} ); diff --git a/app/src/components/plugins/Plugin.tsx b/app/src/components/plugins/Plugin.tsx new file mode 100644 index 000000000..64a264c29 --- /dev/null +++ b/app/src/components/plugins/Plugin.tsx @@ -0,0 +1,47 @@ +import { Alert, AlertVariant } from '@patternfly/react-core'; +import React, { useContext } from 'react'; + +import { IPluginsContext, PluginsContext } from 'context/PluginsContext'; +import { Plugin as IPlugin } from 'proto/plugins_pb'; +import { plugins } from 'utils/plugins'; + +interface IPluginProps { + isInDrawer: boolean; + plugin: IPlugin.AsObject; + showDetails: (panelContent: React.ReactNode) => void; +} + +const Plugin: React.FunctionComponent = ({ isInDrawer, plugin, showDetails }: IPluginProps) => { + const pluginsContext = useContext(PluginsContext); + const pluginDetails = pluginsContext.getPluginDetails(plugin.name); + + if (!pluginDetails || !plugins.hasOwnProperty(pluginDetails.type)) { + return ( + + {pluginDetails ? ( +

+ The plugin {plugin.name} has an invalide type. +

+ ) : ( +

+ The plugin {plugin.name} was not found. +

+ )} +
+ ); + } + + const Component = plugins[pluginDetails.type].plugin; + + return ( + + ); +}; + +export default Plugin; diff --git a/app/src/components/plugins/PluginDataMissing.tsx b/app/src/components/plugins/PluginDataMissing.tsx new file mode 100644 index 000000000..54e709fc5 --- /dev/null +++ b/app/src/components/plugins/PluginDataMissing.tsx @@ -0,0 +1,35 @@ +import { Button, EmptyState, EmptyStateBody, EmptyStateIcon, Title } from '@patternfly/react-core'; +import React from 'react'; + +interface IPluginDataMissingProps { + title: string; + description: string; + documentation: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + icon: React.ComponentType; +} + +// PluginDataMissing is the component, which is displayed when the user defines a plugin in an Application CR, but the +// property for the type of the plugin is missing. It contains a title, description, icon and a link to the +// corresponding documentation. +const PluginDataMissing: React.FunctionComponent = ({ + title, + description, + icon, + documentation, +}: IPluginDataMissingProps) => { + return ( + + + + {title} + + {description} + + + ); +}; + +export default PluginDataMissing; diff --git a/app/src/components/plugins/PluginPage.tsx b/app/src/components/plugins/PluginPage.tsx new file mode 100644 index 000000000..9e1a72cd9 --- /dev/null +++ b/app/src/components/plugins/PluginPage.tsx @@ -0,0 +1,48 @@ +import { Alert, AlertActionLink, AlertVariant, PageSection } from '@patternfly/react-core'; +import React, { useContext } from 'react'; +import { useHistory, useParams } from 'react-router-dom'; + +import { IPluginsContext, PluginsContext } from 'context/PluginsContext'; +import { plugins } from 'utils/plugins'; + +interface IPluginParams { + name: string; +} + +const PluginPage: React.FunctionComponent = () => { + const history = useHistory(); + const params = useParams(); + const pluginsContext = useContext(PluginsContext); + const pluginDetails = pluginsContext.getPluginDetails(params.name); + + if (!pluginDetails || !plugins.hasOwnProperty(pluginDetails.type)) { + return ( + + + history.push('/')}>Home + + } + > + {pluginDetails ? ( +

+ The plugin {params.name} has an invalide type. +

+ ) : ( +

+ The plugin {params.name} was not found. +

+ )} +
+
+ ); + } + + const Component = plugins[pluginDetails.type].page; + return ; +}; + +export default PluginPage; diff --git a/app/src/context/PluginsContext.tsx b/app/src/context/PluginsContext.tsx new file mode 100644 index 000000000..50d06891a --- /dev/null +++ b/app/src/context/PluginsContext.tsx @@ -0,0 +1,145 @@ +import { Alert, AlertActionLink, AlertVariant, Spinner } from '@patternfly/react-core'; +import React, { useCallback, useEffect, useState } from 'react'; + +import { GetPluginsRequest, GetPluginsResponse, PluginShort } from 'proto/plugins_pb'; +import { PluginsPromiseClient } from 'proto/plugins_grpc_web_pb'; +import { apiURL } from 'utils/constants'; + +// pluginsService is the Plugins gRPC service, which is used to get all configured plugins. +const pluginsService = new PluginsPromiseClient(apiURL, null, null); + +// IDataState is the state for the PluginsContext. The state contains all plugins, an error message and a loading +// indicator. +interface IDataState { + error: string; + isLoading: boolean; + plugins: PluginShort.AsObject[]; +} + +// IPluginsContext is the plugin context, is contains all plugins. +export interface IPluginsContext { + getPluginDetails: (name: string) => PluginShort.AsObject | undefined; + plugins: PluginShort.AsObject[]; +} + +// PluginsContext is the plugin context object. +export const PluginsContext = React.createContext({ + getPluginDetails: (name: string) => { + return undefined; + }, + plugins: [], +}); + +// PluginsContextConsumer is a React component that subscribes to context changes. This lets you subscribe to a context +// within a function component. +export const PluginsContextConsumer = PluginsContext.Consumer; + +// IPluginsContextProviderProps is the interface for the PluginsContextProvider component. The only valid properties are +// child components of the type ReactElement. +interface IPluginsContextProviderProps { + children: React.ReactElement; +} + +// PluginsContextProvider is a Provider React component that allows consuming components to subscribe to context +// changes. +export const PluginsContextProvider: React.FunctionComponent = ({ + children, +}: IPluginsContextProviderProps) => { + const [data, setData] = useState({ + error: '', + isLoading: true, + plugins: [], + }); + + // fetchData is used to retrieve all plugins from the gRPC API. The retrieved plugins are used in the plugins property + // of the plugins context. The function is called on the first render of the component and in case of an error it can + // be called via the retry button in the Alert component were the error message is shown. + const fetchData = useCallback(async () => { + try { + const getPluginsRequest = new GetPluginsRequest(); + const getPluginsResponse: GetPluginsResponse = await pluginsService.getPlugins(getPluginsRequest, null); + const tmpPlugins = getPluginsResponse.toObject().pluginsList; + + if (tmpPlugins) { + setData({ + error: '', + isLoading: false, + plugins: tmpPlugins, + }); + } else { + setData({ + error: '', + isLoading: false, + plugins: [], + }); + } + } catch (err) { + setData({ + error: err.message, + isLoading: false, + plugins: [], + }); + } + }, []); + + // getPluginDetails returns the single plugin by his name. This allows us to retrieve the plugin type and description + // by his identifier (name). If their is no plugin with the given name the function returns undefined. + const getPluginDetails = (name: string): PluginShort.AsObject | undefined => { + const filteredPlugins = data.plugins.filter((plugin) => plugin.name === name); + if (filteredPlugins.length === 1) { + return filteredPlugins[0]; + } + + return undefined; + }; + + // retry calls the fetchData function and can be triggered via the retry button in the Alert component in case of an + // error. We can not call the fetchData function directly, because we have to set the isLoading property to true + // first. + const retry = (): void => { + setData({ ...data, isLoading: true }); + fetchData(); + }; + + // useEffect is used to call the fetchData function on the first render of the component. + useEffect(() => { + fetchData(); + }, [fetchData]); + + // As long as the isLoading property of the state is true, we are showing a spinner in the cernter of the screen. + if (data.isLoading) { + return ; + } + + // If an error occured during the fetch of the plugins, we are showing the error message in the cernter of the screen + // within an Alert component. The Alert component contains a Retry button to call the fetchData function again. + if (data.error) { + return ( + + Retry + + } + > +

{data.error}

+
+ ); + } + + // If the fetching of the plugins is finished and was successful, we render the context provider and pass in the + // plugins from the state. + return ( + + {children} + + ); +}; diff --git a/app/src/plugins/elasticsearch/ElasticsearchLogs.tsx b/app/src/plugins/elasticsearch/ElasticsearchLogs.tsx new file mode 100644 index 000000000..589a4aa6b --- /dev/null +++ b/app/src/plugins/elasticsearch/ElasticsearchLogs.tsx @@ -0,0 +1,192 @@ +import { Alert, AlertActionLink, AlertVariant, Spinner } from '@patternfly/react-core'; +import React, { useCallback, useEffect, useState } from 'react'; + +import { + Bucket, + ElasticsearchPromiseClient, + GetLogsRequest, + GetLogsResponse, + Query, +} from 'proto/elasticsearch_grpc_web_pb'; +import { IDocument, IElasticsearchOptions, getFields } from 'plugins/elasticsearch/helpers'; +import ElasticsearchLogsGrid from 'plugins/elasticsearch/ElasticsearchLogsGrid'; +import { apiURL } from 'utils/constants'; + +// elasticsearchService is the gRPC service to get the logs from an Elasticsearch instance. +const elasticsearchService = new ElasticsearchPromiseClient(apiURL, null, null); + +// IDataState is the interface for the state of the ElasticsearchLogs component. It contains all the results of an +// Elasticsearch query. +interface IDataState { + buckets: Bucket.AsObject[]; + documents: IDocument[]; + error: string; + fields: string[]; + hits: number; + isLoading: boolean; + scrollID: string; + took: number; +} + +// IElasticsearchLogsProps is the interface for the properties of the ElasticsearchLogs component, next to the options +// for an Elasticsearch query, we also need the name of the datasource, the name of the query and some other functions +// to modify the properties. +// The queryName is only present, when the query is executed via the ElasticsearchPlugin component, so that we can use +// this property to decide if the user is using Elasticsearch as plugin or via the plugin page. +interface IElasticsearchLogsProps extends IElasticsearchOptions { + name: string; + queryName: string; + isInDrawer: boolean; + setDocument: (document: React.ReactNode) => void; + setScrollID: (scrollID: string) => void; + selectField?: (field: string) => void; +} + +// ElasticsearchLogs is a wrapper component for the Elasticsearch results view (ElasticsearchLogsGrid), it is used to +// fetch all requiered data. The query parameters are passed to this component via props. +const ElasticsearchLogs: React.FunctionComponent = ({ + name, + queryName, + isInDrawer, + fields, + query, + scrollID, + timeEnd, + timeStart, + setDocument, + setScrollID, + selectField, +}: IElasticsearchLogsProps) => { + const [data, setData] = useState({ + buckets: [], + documents: [], + error: '', + fields: [], + hits: 0, + isLoading: true, + scrollID: '', + took: 0, + }); + + // fetchLogs is used to fetch the logs of for a given query in the selected time range. When the query was successful, + // we have to check if a scroll id was already present, if this is the case the query was executed within a previous + // query, so that we have to add the returned documents to the existing want. We also have to omit some other + // properties in this case. + const fetchLogs = useCallback(async (): Promise => { + try { + if (!scrollID) { + setData({ + buckets: [], + documents: [], + error: '', + fields: [], + hits: 0, + isLoading: true, + scrollID: '', + took: 0, + }); + } + + const q = new Query(); + q.setQuery(query); + + const getLogsRequest = new GetLogsRequest(); + getLogsRequest.setName(name); + getLogsRequest.setScrollid(scrollID ? scrollID : ''); + getLogsRequest.setTimeend(timeEnd); + getLogsRequest.setTimestart(timeStart); + getLogsRequest.setQuery(q); + + const getLogsResponse: GetLogsResponse = await elasticsearchService.getLogs(getLogsRequest, null); + const tmpLogsResponse = getLogsResponse.toObject(); + const parsedLogs = JSON.parse(tmpLogsResponse.logs); + + // When the scrollID isn't present, this was a new query, so that we have to set the documents, buckets, hits, + // took and fields. The fields are generated via the getFields function, from the first 10 documents. + // If the scrollID is present we just add the new documents to the existing one, and ignoring the other fields. + if (!scrollID) { + setData({ + buckets: tmpLogsResponse.bucketsList, + documents: parsedLogs, + error: '', + fields: getFields(parsedLogs.slice(parsedLogs.length > 10 ? 10 : parsedLogs.length)), + hits: tmpLogsResponse.hits, + isLoading: false, + scrollID: tmpLogsResponse.scrollid, + took: tmpLogsResponse.took, + }); + } else { + setData((d) => { + return { + ...d, + documents: [...d.documents, ...parsedLogs], + error: '', + isLoading: false, + scrollID: tmpLogsResponse.scrollid, + }; + }); + } + } catch (err) { + setData({ + buckets: [], + documents: [], + error: err.message, + fields: [], + hits: 0, + isLoading: false, + scrollID: '', + took: 0, + }); + } + }, [name, query, scrollID, timeEnd, timeStart]); + + // useEffect is used to call the fetchLogs function every time the required props are changing. + useEffect(() => { + fetchLogs(); + }, [fetchLogs]); + + // When the isLoading property is true, we render a spinner as loading indicator for the user. + if (data.isLoading) { + return ; + } + + // In case of an error, we show an Alert component, with the error message, while the request failed. + if (data.error) { + return ( + + Retry + + } + > +

{data.error}

+
+ ); + } + + return ( + + ); +}; + +export default ElasticsearchLogs; diff --git a/app/src/plugins/elasticsearch/ElasticsearchLogsBuckets.tsx b/app/src/plugins/elasticsearch/ElasticsearchLogsBuckets.tsx new file mode 100644 index 000000000..db5cae505 --- /dev/null +++ b/app/src/plugins/elasticsearch/ElasticsearchLogsBuckets.tsx @@ -0,0 +1,122 @@ +import { Card, CardActions, CardBody, CardHeader, CardHeaderMain } from '@patternfly/react-core'; +import { + Chart, + ChartAxis, + ChartBar, + ChartLegendTooltip, + ChartThemeColor, + createContainer, +} from '@patternfly/react-charts'; +import React, { useEffect, useRef, useState } from 'react'; + +import { Bucket } from 'proto/elasticsearch_grpc_web_pb'; +import ElasticsearchLogsBucketsAction from 'plugins/elasticsearch/ElasticsearchLogsBucketsAction'; +import { formatTime } from 'utils/helpers'; + +interface ILabels { + datum: Bucket.AsObject; +} + +export interface IElasticsearchLogsBucketsProps { + name: string; + queryName: string; + buckets: Bucket.AsObject[]; + fields: string[]; + hits: number; + query: string; + timeEnd: number; + timeStart: number; + took: number; +} + +// ElasticsearchLogsBuckets renders a bar chart with the distribution of the number of logs accross the selected time +// range. +const ElasticsearchLogsBuckets: React.FunctionComponent = ({ + name, + queryName, + buckets, + fields, + hits, + query, + timeEnd, + timeStart, + took, +}: IElasticsearchLogsBucketsProps) => { + 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 ( + + {queryName ? ( + + + {queryName} ({hits} Documents in {took} Milliseconds) + + + + + + ) : ( + + + {hits} Documents in {took} Milliseconds + + + )} + + {buckets.length > 0 ? ( +
+ `${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} + > + + + +
+ ) : null} +
+
+ ); +}; + +export default ElasticsearchLogsBuckets; diff --git a/app/src/plugins/elasticsearch/ElasticsearchLogsBucketsAction.tsx b/app/src/plugins/elasticsearch/ElasticsearchLogsBucketsAction.tsx new file mode 100644 index 000000000..2d9226508 --- /dev/null +++ b/app/src/plugins/elasticsearch/ElasticsearchLogsBucketsAction.tsx @@ -0,0 +1,51 @@ +import { Dropdown, DropdownItem, KebabToggle } from '@patternfly/react-core'; +import React, { useState } from 'react'; +import { Link } from 'react-router-dom'; + +interface IElasticsearchLogsBucketsActionProps { + name: string; + fields: string[]; + query: string; + timeEnd: number; + timeStart: number; +} + +// ElasticsearchLogsBucketsAction is a dropdown component, which provides various actions for an Elasticsearch query. +// For example it can be used to display a link for the query, which will redirect the user to the page of the +// Elasticsearch plugin. +const ElasticsearchLogsBucketsAction: React.FunctionComponent = ({ + name, + fields, + query, + timeEnd, + timeStart, +}: IElasticsearchLogsBucketsActionProps) => { + const [show, setShow] = useState(false); + const fieldParameters = fields.map((field) => `&field=${field}`); + + return ( + setShow(!show)} />} + isOpen={show} + isPlain={true} + position="right" + dropdownItems={[ + 0 ? fieldParameters.join('') : '' + }&timeEnd=${timeEnd}&timeStart=${timeStart}`} + target="_blank" + > + Explore + + } + />, + ]} + /> + ); +}; + +export default ElasticsearchLogsBucketsAction; diff --git a/app/src/plugins/elasticsearch/ElasticsearchLogsDocument.tsx b/app/src/plugins/elasticsearch/ElasticsearchLogsDocument.tsx new file mode 100644 index 000000000..725a6cd22 --- /dev/null +++ b/app/src/plugins/elasticsearch/ElasticsearchLogsDocument.tsx @@ -0,0 +1,46 @@ +import { + DrawerActions, + DrawerCloseButton, + DrawerHead, + DrawerPanelBody, + DrawerPanelContent, +} from '@patternfly/react-core'; +import React from 'react'; + +import { IDocument, formatTimeWrapper } from 'plugins/elasticsearch/helpers'; +import Editor from 'components/Editor'; +import Title from 'components/Title'; + +export interface IElasticsearchLogsDocumentProps { + 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 ElasticsearchLogsDocument: React.FunctionComponent = ({ + document, + close, +}: IElasticsearchLogsDocumentProps) => { + return ( + + + + <DrawerActions style={{ padding: 0 }}> + <DrawerCloseButton onClose={close} /> + </DrawerActions> + </DrawerHead> + + <DrawerPanelBody> + <Editor value={JSON.stringify(document, null, 2)} mode="json" readOnly={true} /> + <p> </p> + </DrawerPanelBody> + </DrawerPanelContent> + ); +}; + +export default ElasticsearchLogsDocument; diff --git a/app/src/plugins/elasticsearch/ElasticsearchLogsDocuments.tsx b/app/src/plugins/elasticsearch/ElasticsearchLogsDocuments.tsx new file mode 100644 index 000000000..b0353f938 --- /dev/null +++ b/app/src/plugins/elasticsearch/ElasticsearchLogsDocuments.tsx @@ -0,0 +1,54 @@ +import { TableComposable, TableVariant, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; +import React from 'react'; + +import { IDocument, formatTimeWrapper, getProperty } from 'plugins/elasticsearch/helpers'; + +export interface IElasticsearchLogsDocumentsProps { + selectedFields: string[]; + documents: IDocument[]; + select?: (doc: IDocument) => void; +} + +// ElasticsearchLogsDocuments 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 ElasticsearchLogsDocuments: React.FunctionComponent<IElasticsearchLogsDocumentsProps> = ({ + selectedFields, + documents, + select, +}: IElasticsearchLogsDocumentsProps) => { + return ( + <div style={{ maxWidth: '100%', overflowX: 'scroll' }}> + <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 ElasticsearchLogsDocuments; diff --git a/app/src/plugins/elasticsearch/ElasticsearchLogsFields.tsx b/app/src/plugins/elasticsearch/ElasticsearchLogsFields.tsx new file mode 100644 index 000000000..93bb5fba5 --- /dev/null +++ b/app/src/plugins/elasticsearch/ElasticsearchLogsFields.tsx @@ -0,0 +1,47 @@ +import { SimpleList, SimpleListItem } from '@patternfly/react-core'; +import React from 'react'; + +export interface IElasticsearchLogsFieldsProps { + fields: string[]; + selectedFields: string[]; + selectField: (field: string) => void; +} + +// ElasticsearchLogsFields 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 ElasticsearchLogsFields: React.FunctionComponent<IElasticsearchLogsFieldsProps> = ({ + fields, + selectedFields, + selectField, +}: IElasticsearchLogsFieldsProps) => { + return ( + <React.Fragment> + {selectedFields.length > 0 ? <p className="pf-u-font-size-xs pf-u-color-400">Selected Fields</p> : null} + + {selectedFields.length > 0 ? ( + <SimpleList aria-label="Selected Fields" isControlled={false}> + {selectedFields.map((selectedField, index) => ( + <SimpleListItem key={index} onClick={(): void => selectField(selectedField)} isActive={false}> + {selectedField} + </SimpleListItem> + ))} + </SimpleList> + ) : null} + + {fields.length > 0 ? <p className="pf-u-font-size-xs pf-u-color-400">Fields</p> : null} + + {fields.length > 0 ? ( + <SimpleList aria-label="Fields" isControlled={false}> + {fields.map((field, index) => ( + <SimpleListItem key={index} onClick={(): void => selectField(field)} isActive={false}> + {field} + </SimpleListItem> + ))} + </SimpleList> + ) : null} + </React.Fragment> + ); +}; + +export default ElasticsearchLogsFields; diff --git a/app/src/plugins/elasticsearch/ElasticsearchLogsGrid.tsx b/app/src/plugins/elasticsearch/ElasticsearchLogsGrid.tsx new file mode 100644 index 000000000..55a5ee21d --- /dev/null +++ b/app/src/plugins/elasticsearch/ElasticsearchLogsGrid.tsx @@ -0,0 +1,101 @@ +import { Button, ButtonVariant, Grid, GridItem } from '@patternfly/react-core'; +import React from 'react'; + +import { Bucket } from 'proto/elasticsearch_grpc_web_pb'; +import ElasticsearchLogsBuckets from 'plugins/elasticsearch/ElasticsearchLogsBuckets'; +import ElasticsearchLogsDocument from 'plugins/elasticsearch/ElasticsearchLogsDocument'; +import ElasticsearchLogsDocuments from 'plugins/elasticsearch/ElasticsearchLogsDocuments'; +import ElasticsearchLogsFields from 'plugins/elasticsearch/ElasticsearchLogsFields'; +import { IDocument } from 'plugins/elasticsearch/helpers'; + +// IElasticsearchLogsGridProps is the interface for all required properties for the ElasticsearchLogsGrid component. +interface IElasticsearchLogsGridProps { + name: string; + queryName: string; + buckets: Bucket.AsObject[]; + documents: IDocument[]; + fields: string[]; + hits: number; + query: string; + scrollID: string; + selectedFields: string[]; + timeEnd: number; + timeStart: number; + took: number; + setDocument: (document: React.ReactNode) => void; + setScrollID: (scrollID: string) => void; + selectField?: (field: string) => void; +} + +// ElasticsearchLogsGrid renders a grid, for the Elasticsearch results. The grid contains a list of fields and selected +// fields, a header with the distribution of the log lines accross the selected time range, a table with the log lines +// and a load more button. +const ElasticsearchLogsGrid: React.FunctionComponent<IElasticsearchLogsGridProps> = ({ + name, + queryName, + buckets, + documents, + fields, + hits, + query, + scrollID, + selectedFields, + timeEnd, + timeStart, + took, + setDocument, + setScrollID, + selectField, +}: IElasticsearchLogsGridProps) => { + // showFields is used to define if we want to show the fields list in the grid or not. If the queryName isn't present, + // which can only happen in the page view, we show the logs. In that way we can save some space in the plugin view, + // where a user can select the fields via the Application CR. + const showFields = !queryName && selectField ? true : false; + + return ( + <Grid hasGutter={true}> + {showFields ? ( + <GridItem sm={12} md={12} lg={3} xl={2} xl2={2}> + {(fields.length > 0 || selectedFields.length > 0) && selectField ? ( + <ElasticsearchLogsFields fields={fields} selectedFields={selectedFields} selectField={selectField} /> + ) : null} + </GridItem> + ) : null} + <GridItem sm={12} md={12} lg={9} xl={showFields ? 10 : 12} xl2={showFields ? 10 : 12}> + <ElasticsearchLogsBuckets + name={name} + queryName={queryName} + buckets={buckets} + fields={selectedFields} + hits={hits} + query={query} + timeEnd={timeEnd} + timeStart={timeStart} + took={took} + /> + + <p> </p> + + {documents.length > 0 ? ( + <ElasticsearchLogsDocuments + selectedFields={selectedFields} + documents={documents} + select={(doc: IDocument): void => + setDocument(<ElasticsearchLogsDocument document={doc} close={(): void => setDocument(undefined)} />) + } + /> + ) : null} + + <p> </p> + + {scrollID !== '' && documents.length > 0 ? ( + <Button variant={ButtonVariant.primary} isBlock={true} onClick={(): void => setScrollID(scrollID)}> + Load more + </Button> + ) : null} + </GridItem> + </Grid> + ); +}; + +export default ElasticsearchLogsGrid; diff --git a/app/src/plugins/elasticsearch/ElasticsearchPage.tsx b/app/src/plugins/elasticsearch/ElasticsearchPage.tsx new file mode 100644 index 000000000..9c6f59d3b --- /dev/null +++ b/app/src/plugins/elasticsearch/ElasticsearchPage.tsx @@ -0,0 +1,110 @@ +import { + Drawer, + DrawerContent, + DrawerContentBody, + PageSection, + PageSectionVariants, + Title, +} from '@patternfly/react-core'; +import React, { useEffect, useState } from 'react'; +import { useHistory, useLocation } from 'react-router-dom'; + +import { IElasticsearchOptions, getOptionsFromSearch } from 'plugins/elasticsearch/helpers'; +import ElasticsearchLogs from 'plugins/elasticsearch/ElasticsearchLogs'; +import ElasticsearchPageToolbar from 'plugins/elasticsearch/ElasticsearchPageToolbar'; +import { IPluginPageProps } from 'utils/plugins'; + +// ElasticsearchPage implements the page component for the Elasticsearch plugin. It is used to render the toolbar and +// the drawer for Elasticsearch. +const ElasticsearchPage: React.FunctionComponent<IPluginPageProps> = ({ name, description }: IPluginPageProps) => { + const history = useHistory(); + const location = useLocation(); + const [options, setOptions] = useState<IElasticsearchOptions>(getOptionsFromSearch(location.search)); + const [document, setDocument] = useState<React.ReactNode>(undefined); + + // changeOptions is used to change the options for an Elasticsearch query. Instead of directly modifying the options + // state we change the URL parameters. + const changeOptions = (opts: IElasticsearchOptions): void => { + const fields = opts.fields ? opts.fields.map((field) => `&field=${field}`) : undefined; + + history.push({ + pathname: location.pathname, + search: `?query=${opts.query}${fields && fields.length > 0 ? fields.join('') : ''}&timeEnd=${ + opts.timeEnd + }&timeStart=${opts.timeStart}`, + }); + }; + + // selectField is used to add a field as parameter, when it isn't present and to remove a fields from as parameter, + // when it is already present via the changeOptions function. + const selectField = (field: string): void => { + let tmpFields: string[] = []; + if (options.fields) { + tmpFields = [...options.fields]; + } + + if (tmpFields.includes(field)) { + tmpFields = tmpFields.filter((f) => f !== field); + } else { + tmpFields.push(field); + } + + changeOptions({ ...options, fields: tmpFields }); + }; + + // setScrollID is used to set the scroll id for pageination. We do not set the scroll id via the search location to + // allow sharing of the current query. + const setScrollID = (scrollID: string): void => { + setOptions({ ...options, scrollID: scrollID }); + }; + + // useEffect is used to set the options every time the search location for the current URL changes. The URL is changed + // via the changeOptions function. When the search location is changed we modify the options state. + useEffect(() => { + setOptions(getOptionsFromSearch(location.search)); + }, [location.search]); + + return ( + <React.Fragment> + <PageSection variant={PageSectionVariants.light}> + <Title headingLevel="h6" size="xl"> + {name} + +

{description}

+ + + + + + + + {options.query ? ( + + ) : null} + + + + + + ); +}; + +export default ElasticsearchPage; diff --git a/app/src/plugins/elasticsearch/ElasticsearchPageToolbar.tsx b/app/src/plugins/elasticsearch/ElasticsearchPageToolbar.tsx new file mode 100644 index 000000000..5a6ec04ae --- /dev/null +++ b/app/src/plugins/elasticsearch/ElasticsearchPageToolbar.tsx @@ -0,0 +1,91 @@ +import { + Button, + ButtonVariant, + TextInput, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, + ToolbarToggleGroup, +} from '@patternfly/react-core'; +import React, { 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 Options, { IAdditionalFields } from 'components/Options'; +import { IElasticsearchOptions } from 'plugins/elasticsearch/helpers'; + +// IElasticsearchPageToolbarProps is the interface for all properties, which can be passed to the +// ElasticsearchPageToolbar component. This are all available Elasticsearch options and a function to write changes to +// these properties back to the parent component. +interface IElasticsearchPageToolbarProps extends IElasticsearchOptions { + setOptions: (data: IElasticsearchOptions) => void; +} + +// ElasticsearchPageToolbar is the toolbar for the Elasticsearch plugin page. It allows a user to specify query and to +// select a start time and end time for the query. +const ElasticsearchPageToolbar: React.FunctionComponent = ({ + query, + queryName, + timeEnd, + timeStart, + setOptions, +}: IElasticsearchPageToolbarProps) => { + const [data, setData] = useState({ + query: query, + queryName: queryName, + timeEnd: timeEnd, + timeStart: timeStart, + }); + + // changeQuery changes the value of a query. + const changeQuery = (value: string): void => { + setData({ ...data, query: value }); + }; + + // changeOptions changes the Elasticsearch options. This function is passed to the shared Options component. + const changeOptions = ( + additionalFields: IAdditionalFields[] | undefined, + timeEnd: number, + timeStart: number, + ): void => { + setData({ + ...data, + timeEnd: timeEnd, + timeStart: timeStart, + }); + }; + + // onEnter is used to detect if the user pressed the "ENTER" key. If this is the case we are calling the setOptions + // function to trigger the search. + // use "SHIFT" + "ENTER". + const onEnter = (e: React.KeyboardEvent | undefined): void => { + if (e?.key === 'Enter' && !e.shiftKey) { + setOptions(data); + } + }; + + return ( + + + } breakpoint="lg"> + + + + + + + + + + + + + + + ); +}; + +export default ElasticsearchPageToolbar; diff --git a/app/src/plugins/elasticsearch/ElasticsearchPlugin.tsx b/app/src/plugins/elasticsearch/ElasticsearchPlugin.tsx new file mode 100644 index 000000000..28b72af5c --- /dev/null +++ b/app/src/plugins/elasticsearch/ElasticsearchPlugin.tsx @@ -0,0 +1,86 @@ +import React, { useState } from 'react'; +import ListIcon from '@patternfly/react-icons/dist/js/icons/list-icon'; + +import ElasticsearchLogs from 'plugins/elasticsearch/ElasticsearchLogs'; +import ElasticsearchPluginToolbar from 'plugins/elasticsearch/ElasticsearchPluginToolbar'; +import { IElasticsearchOptions } from 'plugins/elasticsearch/helpers'; +import { IPluginProps } from 'utils/plugins'; +import PluginDataMissing from 'components/plugins/PluginDataMissing'; + +// ElasticsearchPlugin is the plugin component for the Elasticsearch plugin. It renders a toolbar, which allows a user +// to select the specified queries for an application. +const ElasticsearchPlugin: React.FunctionComponent = ({ + isInDrawer, + name, + description, + plugin, + showDetails, +}: IPluginProps) => { + // initialQuery is the initial selected / first query in a list of queries. If the user doesn't have specified any + // queries the initialQuery is undefined. + const initialQuery = + plugin.elasticsearch && plugin.elasticsearch.queriesList.length > 0 + ? plugin.elasticsearch.queriesList[0] + : undefined; + + const [options, setOptions] = useState({ + fields: initialQuery && initialQuery.fieldsList.length > 0 ? initialQuery.fieldsList : undefined, + query: initialQuery ? initialQuery.query : '', + queryName: initialQuery ? initialQuery.name : '', + scrollID: '', + timeEnd: Math.floor(Date.now() / 1000), + timeStart: Math.floor(Date.now() / 1000) - 3600, + }); + + // setScrollID changed the scroll id, so that pagination is also supported in the plugins view. + const setScrollID = (scrollID: string): void => { + setOptions({ ...options, scrollID: scrollID }); + }; + + // When the elasticsearch property of the plugin is missing, we use the shared PluginDataMissing component, with a + // link to the corresponding documentation for the Elasticsearch plugin. + if (!plugin.elasticsearch) { + return ( + + ); + } + + return ( + + + setOptions({ ...options, fields: fields, query: query, queryName: name }) + } + setTimes={(timeEnd: number, timeStart: number): void => + setOptions({ ...options, timeEnd: timeEnd, timeStart: timeStart }) + } + /> +

 

+ {options.query && options.queryName ? ( + + ) : null} +
+ ); +}; + +export default ElasticsearchPlugin; diff --git a/app/src/plugins/elasticsearch/ElasticsearchPluginToolbar.tsx b/app/src/plugins/elasticsearch/ElasticsearchPluginToolbar.tsx new file mode 100644 index 000000000..9cd50eb50 --- /dev/null +++ b/app/src/plugins/elasticsearch/ElasticsearchPluginToolbar.tsx @@ -0,0 +1,93 @@ +import React, { useState } from 'react'; +import { + Select, + SelectOption, + SelectOptionObject, + SelectVariant, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, + ToolbarToggleGroup, +} from '@patternfly/react-core'; +import FilterIcon from '@patternfly/react-icons/dist/js/icons/filter-icon'; + +import Options, { IAdditionalFields } from 'components/Options'; +import { Query } from 'proto/elasticsearch_grpc_web_pb'; + +// IElasticsearchPluginToolbarProps is the interface for all properties, which can be passed to the +// ElasticsearchPluginToolbar component. This are all available Elasticsearch options and a function to write changes to +// these properties back to the parent component. +interface IElasticsearchPluginToolbarProps { + queryName: string; + queries: Query.AsObject[]; + timeEnd: number; + timeStart: number; + setQuery: (name: string, query: string, fields: string[]) => void; + setTimes: (timeEnd: number, timeStart: number) => void; +} + +// ElasticsearchPluginToolbar is the toolbar for the Elasticsearch plugin page. It allows a user to specify query and to +// select a start time and end time for the query. +const ElasticsearchPluginToolbar: React.FunctionComponent = ({ + queryName, + queries, + timeEnd, + timeStart, + setQuery, + setTimes, +}: IElasticsearchPluginToolbarProps) => { + const [show, setShow] = useState(false); + + // changeOptions is used to change the start and end time of for an query. + const changeOptions = ( + additionalFields: IAdditionalFields[] | undefined, + timeEnd: number, + timeStart: number, + ): void => { + setTimes(timeEnd, timeStart); + }; + + // onSelect is used to change the query, when a user selects a query from the select component. + const onSelect = ( + event: React.MouseEvent | React.ChangeEvent, + value: string | SelectOptionObject, + ): void => { + const query = queries.filter((q) => q.name === value); + if (query.length === 1) { + setQuery(query[0].name, query[0].query, query[0].fieldsList); + setShow(false); + } + }; + + return ( + + + } breakpoint="lg"> + + + + + + + + + + + + ); +}; + +export default ElasticsearchPluginToolbar; diff --git a/app/src/plugins/elasticsearch/helpers.ts b/app/src/plugins/elasticsearch/helpers.ts new file mode 100644 index 000000000..1dc196111 --- /dev/null +++ b/app/src/plugins/elasticsearch/helpers.ts @@ -0,0 +1,86 @@ +import { formatTime } from 'utils/helpers'; + +// ITimes is the interface for a start and end time. +export interface ITimes { + timeEnd: number; + timeStart: number; +} + +// IElasticsearchOptions is the interface for all options, which can be set for an Elasticsearch query. +export interface IElasticsearchOptions extends ITimes { + fields?: string[]; + query: string; + queryName: string; + scrollID?: string; +} + +// 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; +} + +// getOptionsFromSearch is used to get the Elasticsearch options from a given search location. +export const getOptionsFromSearch = (search: string): IElasticsearchOptions => { + const params = new URLSearchParams(search); + const fields = params.getAll('field'); + const query = params.get('query'); + const timeEnd = params.get('timeEnd'); + const timeStart = params.get('timeStart'); + + return { + fields: fields.length > 0 ? fields : undefined, + query: query ? query : '', + queryName: '', + scrollID: '', + timeEnd: timeEnd ? parseInt(timeEnd as string) : Math.floor(Date.now() / 1000), + timeStart: timeStart ? parseInt(timeStart as string) : Math.floor(Date.now() / 1000) - 3600, + }; +}; + +// 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/plugins/prometheus/PrometheusChartActions.tsx b/app/src/plugins/prometheus/PrometheusChartActions.tsx new file mode 100644 index 000000000..bf4e26a41 --- /dev/null +++ b/app/src/plugins/prometheus/PrometheusChartActions.tsx @@ -0,0 +1,49 @@ +import { Dropdown, DropdownItem, KebabToggle } from '@patternfly/react-core'; +import React, { useState } from 'react'; +import { Link } from 'react-router-dom'; + +import { ITimes } from 'plugins/prometheus/helpers'; + +interface IPrometheusChartActionsProps { + name: string; + times: ITimes; + interpolatedQueries: string[]; +} + +// PrometheusChartActions 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 PrometheusChartActions: React.FunctionComponent = ({ + name, + times, + interpolatedQueries, +}: IPrometheusChartActionsProps) => { + const [show, setShow] = useState(false); + const queries = interpolatedQueries.map((query) => `&query=${query}`); + + return ( + setShow(!show)} />} + isOpen={show} + isPlain={true} + position="right" + dropdownItems={[ + 0 ? queries.join('') : '' + }`} + target="_blank" + > + Explore + + } + />, + ]} + /> + ); +}; + +export default PrometheusChartActions; diff --git a/app/src/plugins/prometheus/PrometheusChartDefault.tsx b/app/src/plugins/prometheus/PrometheusChartDefault.tsx new file mode 100644 index 000000000..cf190a3e8 --- /dev/null +++ b/app/src/plugins/prometheus/PrometheusChartDefault.tsx @@ -0,0 +1,108 @@ +import { + Chart, + ChartArea, + ChartAxis, + ChartBar, + ChartGroup, + ChartLegendTooltip, + ChartLine, + ChartStack, + ChartThemeColor, + createContainer, +} from '@patternfly/react-charts'; +import React, { useEffect, useRef, useState } from 'react'; + +import { Data, Metrics } from 'proto/prometheus_grpc_web_pb'; +import { formatTime } from 'utils/helpers'; + +interface ILabels { + datum: Data.AsObject; +} + +export interface IPrometheusChartDefaultProps { + type: string; + unit: string; + stacked: boolean; + disableLegend?: boolean; + metrics: Metrics.AsObject[]; +} + +// 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 PrometheusChartDefault: React.FunctionComponent = ({ + type, + unit, + stacked, + disableLegend, + metrics, +}: IPrometheusChartDefaultProps) => { + 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); + } + }, []); + + // In the following we are creating the container for the cursor container, we are generating the data for the legend + // and we are creating the series component for each metric. + const CursorVoronoiContainer = createContainer('voronoi', 'cursor'); + const legendData = metrics.map((metric, index) => ({ childName: `index${index}`, name: metric.label })); + 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 PrometheusChartDefault; diff --git a/app/src/plugins/prometheus/PrometheusChartSparkline.tsx b/app/src/plugins/prometheus/PrometheusChartSparkline.tsx new file mode 100644 index 000000000..d2d3e8fb7 --- /dev/null +++ b/app/src/plugins/prometheus/PrometheusChartSparkline.tsx @@ -0,0 +1,50 @@ +import { ChartArea, ChartGroup } from '@patternfly/react-charts'; +import React, { useEffect, useRef, useState } from 'react'; + +import { Metrics } from 'proto/prometheus_grpc_web_pb'; + +export interface IPrometheusChartSparklineProps { + unit: string; + metrics: Metrics.AsObject[]; +} + +// PrometheusChartSparkline 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 PrometheusChartSparkline: React.FunctionComponent = ({ + unit, + metrics, +}: IPrometheusChartSparklineProps) => { + 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); + } + }, []); + + // When the component doesn't received any metrics we do not render anything. + if (metrics.length === 0) { + return null; + } + + return ( +
+
+ {metrics[0].dataList[metrics[0].dataList.length - 1].y} {unit} +
+ + {metrics.map((metric, index) => ( + + ))} + +
+ ); +}; + +export default PrometheusChartSparkline; diff --git a/app/src/plugins/prometheus/PrometheusPage.tsx b/app/src/plugins/prometheus/PrometheusPage.tsx new file mode 100644 index 000000000..1c6b9e127 --- /dev/null +++ b/app/src/plugins/prometheus/PrometheusPage.tsx @@ -0,0 +1,141 @@ +import { + Alert, + AlertActionLink, + AlertVariant, + PageSection, + PageSectionVariants, + Spinner, + Title, +} from '@patternfly/react-core'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useHistory, useLocation } from 'react-router-dom'; + +import { + GetMetricsRequest, + GetMetricsResponse, + Metrics, + PrometheusPromiseClient, + Query, +} from 'proto/prometheus_grpc_web_pb'; +import { IPrometheusOptions, getOptionsFromSearch } from 'plugins/prometheus/helpers'; +import { IPluginPageProps } from 'utils/plugins'; +import PrometheusPageData from 'plugins/prometheus/PrometheusPageData'; +import PrometheusPageToolbar from 'plugins/prometheus/PrometheusPageToolbar'; +import { apiURL } from 'utils/constants'; + +// prometheusService is the gRPC service to run queries against Prometheus. +const prometheusService = new PrometheusPromiseClient(apiURL, null, null); + +// IDataState is the interface for the data state, which consists our of a error message, loading identicator and the +// loaded metrics. +export interface IDataState { + error: string; + isLoading: boolean; + metrics: Metrics.AsObject[]; +} + +// PrometheusPage is the page component for the Prometheus plugin. The page can be used to directly query a Prometheus +// instance. +const PrometheusPage: React.FunctionComponent = ({ name, description }: IPluginPageProps) => { + const history = useHistory(); + const location = useLocation(); + const [data, setData] = useState({ + error: '', + isLoading: false, + metrics: [], + }); + const [options, setOptions] = useState(getOptionsFromSearch(location.search)); + + // changeOptions is used to change the options for an Prometheus query. Instead of directly modifying the options + // state we change the URL parameters. + const changeOptions = (opts: IPrometheusOptions): void => { + const queries = opts.queries ? opts.queries.map((query) => `&query=${query}`) : []; + + history.push({ + pathname: location.pathname, + search: `?resolution=${opts.resolution}&timeEnd=${opts.timeEnd}&timeStart=${opts.timeStart}${ + queries.length > 0 ? queries.join('') : '' + }`, + }); + }; + + // fetchData is used to retrieve the metrics for the given queries in the selected time range with the selected + // resolution. + const fetchData = useCallback(async (): Promise => { + try { + if (options.queries && options.queries.length > 0 && options.queries[0] !== '') { + setData({ error: '', isLoading: true, metrics: [] }); + + const queries: Query[] = []; + for (const q of options.queries) { + const query = new Query(); + query.setQuery(q); + queries.push(query); + } + + const getMetricsRequest = new GetMetricsRequest(); + getMetricsRequest.setName(name); + getMetricsRequest.setTimeend(options.timeEnd); + getMetricsRequest.setTimestart(options.timeStart); + getMetricsRequest.setResolution(options.resolution); + getMetricsRequest.setQueriesList(queries); + + const getMetricsResponse: GetMetricsResponse = await prometheusService.getMetrics(getMetricsRequest, null); + setData({ error: '', isLoading: false, metrics: getMetricsResponse.toObject().metricsList }); + } + } catch (err) { + setData({ error: err.message, isLoading: false, metrics: [] }); + } + }, [name, options]); + + // useEffect is used to call the fetchData function everytime the Prometheus options are changed. + useEffect(() => { + fetchData(); + }, [fetchData]); + + // useEffect is used to set the options every time the search location for the current URL changes. The URL is changed + // via the changeOptions function. When the search location is changed we modify the options state. + useEffect(() => { + setOptions(getOptionsFromSearch(location.search)); + }, [location.search]); + + return ( + + + + {name} + +

{description}

+ +
+ + + {data.isLoading ? ( + + ) : data.error ? ( + + Retry +
+ } + > +

{data.error}

+ + ) : ( + + )} + + + ); +}; + +export default PrometheusPage; diff --git a/app/src/plugins/prometheus/PrometheusPageData.tsx b/app/src/plugins/prometheus/PrometheusPageData.tsx new file mode 100644 index 000000000..e4f88e486 --- /dev/null +++ b/app/src/plugins/prometheus/PrometheusPageData.tsx @@ -0,0 +1,92 @@ +import { + Card, + CardBody, + Flex, + FlexItem, + SimpleList, + SimpleListItem, + ToggleGroup, + ToggleGroupItem, +} from '@patternfly/react-core'; +import React, { useState } from 'react'; + +import { Metrics } from 'proto/prometheus_grpc_web_pb'; +import PrometheusChartDefault from 'plugins/prometheus/PrometheusChartDefault'; + +interface IPrometheusPageDataProps { + metrics: Metrics.AsObject[]; +} + +// PrometheusPageData is used to render the fetched metrics, for the user provided queries. By default the corresponding +// chart will render all loaded metrics. When the user selects a specific metric, the chart will only render this +// metrics. A user can also decided, how he wants to see his data: As line vs. area chart or unstacked vs. stacked. +const PrometheusPageData: React.FunctionComponent = ({ + metrics, +}: IPrometheusPageDataProps) => { + const [type, setType] = useState('line'); + const [stacked, setStacked] = useState(false); + const [selectedMetrics, setSelectedMetrics] = useState([]); + + // select is used to select a single metric, which should be shown in the rendered chart. If the currently selected + // metric is clicked again, the filter will be removed and all metrics will be shown in the chart. + const select = (metric: Metrics.AsObject): void => { + if (selectedMetrics.length === 1 && selectedMetrics[0].label === metric.label) { + setSelectedMetrics(metrics); + } else { + setSelectedMetrics([metric]); + } + }; + + // When their are no metrics we do not render anything. + if (metrics.length === 0) { + return null; + } + + return ( + + + + + + setType('line')} /> + setType('area')} /> + + + + + setStacked(false)} /> + setStacked(true)} /> + + + + +

 

+ + + +

 

+ + + {metrics.map((metric, index) => ( + select(metric)} + isActive={selectedMetrics.length === 1 && selectedMetrics[0].label === metric.label} + > + {metric.label} + {metric.dataList[metric.dataList.length - 1].y} + + ))} + +
+
+ ); +}; + +export default PrometheusPageData; diff --git a/app/src/plugins/prometheus/PrometheusPageToolbar.tsx b/app/src/plugins/prometheus/PrometheusPageToolbar.tsx new file mode 100644 index 000000000..00034acc3 --- /dev/null +++ b/app/src/plugins/prometheus/PrometheusPageToolbar.tsx @@ -0,0 +1,117 @@ +import { + Button, + ButtonVariant, + TextArea, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, + ToolbarToggleGroup, +} from '@patternfly/react-core'; +import React, { 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 Options, { IAdditionalFields } from 'components/Options'; +import { IPrometheusOptions } from 'plugins/prometheus/helpers'; + +// IPrometheusPageToolbarProps is the interface for all properties, which can be passed to the PrometheusPageToolbar +// component. This are all available Prometheus options and a function to write changes to these properties back to the +// parent component. +interface IPrometheusPageToolbarProps extends IPrometheusOptions { + setOptions: (data: IPrometheusOptions) => void; +} + +// PrometheusPageToolbar is the toolbar for the Prometheus plugin page. It allows a user to specify query and to select +// a start time, end time and resolution for the query. +const PrometheusPageToolbar: React.FunctionComponent = ({ + queries, + resolution, + timeEnd, + timeStart, + setOptions, +}: IPrometheusPageToolbarProps) => { + const [data, setData] = useState({ + queries: queries, + resolution: resolution, + timeEnd: timeEnd, + timeStart: timeStart, + }); + + // changeQuery changes the value of a single query. + const changeQuery = (index: number, value: string): void => { + const tmpQueries = [...data.queries]; + tmpQueries[index] = value; + setData({ ...data, queries: tmpQueries }); + }; + + // changeOptions changes the Prometheus options. This function is passed to the shared Options component. + const changeOptions = ( + additionalFields: IAdditionalFields[] | undefined, + timeEnd: number, + timeStart: number, + ): void => { + if (additionalFields && additionalFields.length === 1) { + setData({ + ...data, + resolution: additionalFields[0].value, + timeEnd: timeEnd, + timeStart: timeStart, + }); + } + }; + + // onEnter is used to detect if the user pressed the "ENTER" key. If this is the case we will not add a newline. + // Instead of this we are calling the setOptions function to trigger the search. To enter a newline the user has to + // use "SHIFT" + "ENTER". + const onEnter = (e: React.KeyboardEvent | undefined): void => { + if (e?.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + setOptions(data); + } + }; + + return ( + + + } breakpoint="lg"> + + +