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)} />