From 294db779ce35044ca8c6b03f44db5e77ad2dbe71 Mon Sep 17 00:00:00 2001 From: ricoberger Date: Sun, 7 Mar 2021 00:11:24 +0100 Subject: [PATCH] Share datasource options between components This changes the handling of the datasource options, so that they are shared between the components for the Application details. This allows us to use the same time range for metrics, logs and traces. For that the datasource options are now handled in the parent component of the different view, instead of the Metrics, Logs, Traces component. This also allows us, to reflect the changes of the options in the current URL via query parameters, so that a user can share his current view with other users and they see the same time range. Note: Maybe we can do sth. similar also for the users selected variables, so that he also can share these values with other users. --- CHANGELOG.md | 2 + .../components/applications/Application.tsx | 50 +++---- .../applications/details/DetailsLink.tsx | 31 +++++ .../applications/details/DrawerPanel.tsx | 48 +++---- .../components/applications/details/Tabs.tsx | 47 +++++++ .../applications/details/TabsContent.tsx | 122 ++++++++++++++++++ .../applications/details/metrics/Metrics.tsx | 15 ++- 7 files changed, 245 insertions(+), 70 deletions(-) create mode 100644 app/src/components/applications/details/DetailsLink.tsx create mode 100644 app/src/components/applications/details/Tabs.tsx create mode 100644 app/src/components/applications/details/TabsContent.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 76c794ded..1331d4338 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,3 +18,5 @@ NOTE: As semantic versioning states all 0.y.z releases can contain breaking chan - [#1](https://github.com/kobsio/kobs/pull/1): Fix mobile layout for the cluster and namespace filter by using a Toolbar instead of FlexItems. ### Changed + +- [#7](https://github.com/kobsio/kobs/pull/7): Share datasource options between components and allow sharing of URLs. diff --git a/app/src/components/applications/Application.tsx b/app/src/components/applications/Application.tsx index 2d68c2ea1..d206232ce 100644 --- a/app/src/components/applications/Application.tsx +++ b/app/src/components/applications/Application.tsx @@ -7,19 +7,15 @@ import { ListVariant, PageSection, PageSectionVariants, - Tab, - TabContent, - TabTitleText, - Tabs, } from '@patternfly/react-core'; import { Link, useHistory, useParams } from 'react-router-dom'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { GetApplicationRequest, GetApplicationResponse } from 'generated/proto/clusters_pb'; +import Tabs, { DEFAULT_TAB } from 'components/applications/details/Tabs'; import { Application } from 'generated/proto/application_pb'; import { ClustersPromiseClient } from 'generated/proto/clusters_grpc_web_pb'; -import Metrics from 'components/applications/details/metrics/Metrics'; -import Resources from 'components/applications/details/resources/Resources'; +import TabsContent from 'components/applications/details/TabsContent'; import Title from 'components/shared/Title'; import { apiURL } from 'utils/constants'; @@ -39,7 +35,8 @@ const Applications: React.FunctionComponent = () => { const params = useParams(); const [application, setApplication] = useState(undefined); const [error, setError] = useState(''); - const [activeTabKey, setActiveTabKey] = useState('resources'); + + const [tab, setTab] = useState(DEFAULT_TAB); const refResourcesContent = useRef(null); const refMetricsContent = useRef(null); @@ -116,37 +113,20 @@ const Applications: React.FunctionComponent = () => { ))} setActiveTabKey(tabIndex.toString())} - > - Resources} - tabContentId="refResources" - tabContentRef={refResourcesContent} - /> - Metrics} - tabContentId="refMetrics" - tabContentRef={refMetricsContent} - /> - + tab={tab} + setTab={(t: string): void => setTab(t)} + refResourcesContent={refResourcesContent} + refMetricsContent={refMetricsContent} + /> - -
- -
-
- - {/* We have to check if the refMetricsContent is not null, because otherwise the Metrics component will be shown below the resources component. */} -
{refMetricsContent.current ? : null}
-
+
); diff --git a/app/src/components/applications/details/DetailsLink.tsx b/app/src/components/applications/details/DetailsLink.tsx new file mode 100644 index 000000000..eeac7fae0 --- /dev/null +++ b/app/src/components/applications/details/DetailsLink.tsx @@ -0,0 +1,31 @@ +import { Link, useLocation } from 'react-router-dom'; +import React, { useEffect, useState } from 'react'; + +import { Application } from 'generated/proto/application_pb'; + +interface IDetailsLinkProps { + application: Application; +} + +// DetailsLink renders the link to the details page for an application inside the DrawerPanel of the applications page. +// Everytime when the location.search parameter (query parameters) are changing, we are adding the new parameters to the +// link, so that for example a change of the selected time range is also used in the details page. +const DetailsLink: React.FunctionComponent = ({ application }: IDetailsLinkProps) => { + const location = useLocation(); + + const [link, setLink] = useState( + `/applications/${application.getCluster()}/${application.getNamespace()}/${application.getName()}`, + ); + + useEffect(() => { + setLink( + `/applications/${application.getCluster()}/${application.getNamespace()}/${application.getName()}${ + location.search + }`, + ); + }, [application, location.search]); + + return Details; +}; + +export default DetailsLink; diff --git a/app/src/components/applications/details/DrawerPanel.tsx b/app/src/components/applications/details/DrawerPanel.tsx index 054556ba2..37368c9a2 100644 --- a/app/src/components/applications/details/DrawerPanel.tsx +++ b/app/src/components/applications/details/DrawerPanel.tsx @@ -7,16 +7,14 @@ import { List, ListItem, ListVariant, - Tab, - TabTitleText, - Tabs, } from '@patternfly/react-core'; -import React, { useState } from 'react'; +import React, { useRef, useState } from 'react'; import { Link } from 'react-router-dom'; +import Tabs, { DEFAULT_TAB } from 'components/applications/details/Tabs'; import { Application } from 'generated/proto/application_pb'; -import Metrics from 'components/applications/details/metrics/Metrics'; -import Resources from 'components/applications/details/resources/Resources'; +import DetailsLink from 'components/applications/details/DetailsLink'; +import TabsContent from 'components/applications/details/TabsContent'; import Title from 'components/shared/Title'; interface IDrawerPanelProps { @@ -27,7 +25,9 @@ interface IDrawerPanelProps { // DrawerPanel is the drawer panel for an application. It is used to display application details in the applications // page. The details contains information for resources, metrics, logs and traces. const DrawerPanel: React.FunctionComponent = ({ application, close }: IDrawerPanelProps) => { - const [activeTabKey, setActiveTabKey] = useState('resources'); + const [tab, setTab] = useState(DEFAULT_TAB); + const refResourcesContent = useRef(null); + const refMetricsContent = useRef(null); return ( @@ -45,11 +45,7 @@ const DrawerPanel: React.FunctionComponent = ({ application, - - Details - + {application.getLinksList().map((link, index) => ( @@ -61,22 +57,18 @@ const DrawerPanel: React.FunctionComponent = ({ application, setActiveTabKey(tabIndex.toString())} - > - Resources}> -
- -
-
- Metrics}> -
- -
-
-
+ tab={tab} + setTab={(t: string): void => setTab(t)} + refResourcesContent={refResourcesContent} + refMetricsContent={refMetricsContent} + /> + +
); diff --git a/app/src/components/applications/details/Tabs.tsx b/app/src/components/applications/details/Tabs.tsx new file mode 100644 index 000000000..f28e5af93 --- /dev/null +++ b/app/src/components/applications/details/Tabs.tsx @@ -0,0 +1,47 @@ +import { Tabs as PatternflyTabs, Tab, TabTitleText } from '@patternfly/react-core'; +import React from 'react'; + +// DEFAULT_TAB is the first tab, which is selected in the application view. +export const DEFAULT_TAB = 'resources'; + +interface ITabsParams { + tab: string; + setTab(tab: string): void; + refResourcesContent: React.RefObject; + refMetricsContent: React.RefObject; +} + +// Tabs renders the tabs header, which are used by the user to select a section he wants to view for an application. +// We can not use the tab state, within this component, because then the tab change isn't reflected in the TabsContent +// component. So that we have to manage the refs and tab in the parent component. +const Tabs: React.FunctionComponent = ({ + tab, + setTab, + refResourcesContent, + refMetricsContent, +}: ITabsParams) => { + return ( + setTab(tabIndex.toString())} + > + Resources} + tabContentId="refResources" + tabContentRef={refResourcesContent} + /> + Metrics} + tabContentId="refMetrics" + tabContentRef={refMetricsContent} + /> + + ); +}; + +export default Tabs; diff --git a/app/src/components/applications/details/TabsContent.tsx b/app/src/components/applications/details/TabsContent.tsx new file mode 100644 index 000000000..9ae47bc96 --- /dev/null +++ b/app/src/components/applications/details/TabsContent.tsx @@ -0,0 +1,122 @@ +import React, { useState } from 'react'; +import { useHistory, useLocation } from 'react-router-dom'; +import { TabContent } from '@patternfly/react-core'; + +import { Application } from 'generated/proto/application_pb'; +import { IDatasourceOptions } from 'utils/proto'; +import Metrics from 'components/applications/details/metrics/Metrics'; +import Resources from 'components/applications/details/resources/Resources'; + +// IParsedDatasourceOptions is the interface for the parsed query parameters. It must contain the same keys as the +// IDatasourceOptions options, but all keys must be of type string. +interface IParsedDatasourceOptions { + resolution: string; + timeEnd: string; + timeStart: string; +} + +// datasourceOptionsFromLocationSearch is used to parse all query parameters during the first rendering of the +// TabsContent component. When the parameters are not set we return some default options for the datasources. Because it +// could happen that only some parameters are set via location.search, we have to check each property if it contains a +// valid value. If this is the case we are overwriting the default value. +const datasourceOptionsFromLocationSearch = (): IDatasourceOptions => { + const search = window.location.search; + const options: IDatasourceOptions = { + resolution: '', + timeEnd: Math.floor(Date.now() / 1000), + timeStart: Math.floor(Date.now() / 1000) - 3600, + }; + + if (search !== '') { + try { + const parsedOptions: IParsedDatasourceOptions = JSON.parse( + '{"' + search.substr(1).replace(/&/g, '", "').replace(/=/g, '": "') + '"}', + ); + + if (parsedOptions.resolution) options.resolution = parsedOptions.resolution; + if (parsedOptions.timeEnd) options.timeEnd = parseInt(parsedOptions.timeEnd); + if (parsedOptions.timeStart) options.timeStart = parseInt(parsedOptions.timeStart); + } catch (err) { + return options; + } + } + + return options; +}; + +// createSearch creates a string, which can be used within the history.push function as search parameter. For that we +// are looping over each key of the IDatasourceOptions interface and if it contains a value, this value will be added to +// the parameters. +const createSearch = (options: IDatasourceOptions): string => { + const params: string[] = []; + + let option: keyof IDatasourceOptions; + for (option in options) { + if (options[option]) { + params.push(`${option}=${options[option]}`); + } + } + + return `?${params.join('&')}`; +}; + +interface ITabsContent { + application: Application; + tab: string; + refResourcesContent: React.RefObject; + refMetricsContent: React.RefObject; +} + +// TabsContent renders the content for a selected tab from the Tabs component. We also manage the datasource options, +// within this component, so that we can share the selected time range between metrics, logs and traces. +// When the datasource options are changed, we also reflect this change in the URL via query parameters, so that a user +// can share his current view with other users. +const TabsContent: React.FunctionComponent = ({ + application, + tab, + refResourcesContent, + refMetricsContent, +}: ITabsContent) => { + const history = useHistory(); + const location = useLocation(); + const [datasourceOptions, setDatasourceOptions] = useState(datasourceOptionsFromLocationSearch()); + + const changeDatasourceOptions = (options: IDatasourceOptions): void => { + setDatasourceOptions(options); + + history.push({ + pathname: location.pathname, + search: createSearch(options), + }); + }; + + return ( + + +
+ +
+
+ + {/* We have to check if the refMetricsContent is not null, because otherwise the Metrics component will be shown below the resources component. */} +
+ {refMetricsContent.current ? ( + + ) : null} +
+
+
+ ); +}; + +export default TabsContent; diff --git a/app/src/components/applications/details/metrics/Metrics.tsx b/app/src/components/applications/details/metrics/Metrics.tsx index b5d5b80d2..e5efbd24c 100644 --- a/app/src/components/applications/details/metrics/Metrics.tsx +++ b/app/src/components/applications/details/metrics/Metrics.tsx @@ -18,22 +18,23 @@ import { apiURL } from 'utils/constants'; const datasourcesService = new DatasourcesPromiseClient(apiURL, null, null); interface IMetricsProps { + datasourceOptions: IDatasourceOptions; + setDatasourceOptions: (options: IDatasourceOptions) => void; application: Application; } // Metrics the metrics component is used to display the metrics for an application. The metrics view consist of a // toolbar, to display variables and different datasource specific options for the queries. It also contains a charts // view, to display all user defined charts. -const Metrics: React.FunctionComponent = ({ application }: IMetricsProps) => { +const Metrics: React.FunctionComponent = ({ + datasourceOptions, + setDatasourceOptions, + application, +}: IMetricsProps) => { const metrics = application.getMetrics(); const [datasourceName, setDatasourceName] = useState(''); const [datasourceType, setDatasourceType] = useState(''); - const [datasourceOptions, setDatasourceOptions] = useState({ - resolution: '', - timeEnd: Math.floor(Date.now() / 1000), - timeStart: Math.floor(Date.now() / 1000) - 3600, - }); const [variables, setVariables] = useState( metrics ? convertApplicationMetricsVariablesFromProto(metrics.getVariablesList()) : [], ); @@ -105,7 +106,7 @@ const Metrics: React.FunctionComponent = ({ application }: IMetri datasourcenName={datasourceName} datasourceType={datasourceType} datasourceOptions={datasourceOptions} - setDatasourceOptions={(opts): void => setDatasourceOptions(opts)} + setDatasourceOptions={setDatasourceOptions} variables={variables} setVariables={(vars): void => setVariables(vars)} />