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 000000000..3a6ff484c Binary files /dev/null and b/app/public/img/plugins/kobs.png differ diff --git a/app/public/img/plugins/kubernetes.png b/app/public/img/plugins/kubernetes.png new file mode 100644 index 000000000..094b413da Binary files /dev/null and b/app/public/img/plugins/kubernetes.png differ diff --git a/app/public/img/plugins/plugins.png b/app/public/img/plugins/plugins.png new file mode 100644 index 000000000..137c36f3c Binary files /dev/null and b/app/public/img/plugins/plugins.png differ diff --git a/app/public/img/datasources/prometheus.png b/app/public/img/plugins/prometheus.png similarity index 100% rename from app/public/img/datasources/prometheus.png rename to app/public/img/plugins/prometheus.png diff --git a/app/src/App.tsx b/app/src/App.tsx index 715b14872..0feccefc4 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -11,6 +11,8 @@ import Application from 'components/applications/Application'; import Applications from 'components/applications/Applications'; import { ClustersContextProvider } from 'context/ClustersContext'; import Home from 'components/Home'; +import Plugins from 'components/plugins/PluginPage'; +import { PluginsContextProvider } from 'context/PluginsContext'; import Resources from 'components/resources/Resources'; import 'app.css'; @@ -23,16 +25,19 @@ const App: React.FunctionComponent = () => { 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"> + + +