diff --git a/app/package.json b/app/package.json index bdc70047b..5b7c3ce57 100644 --- a/app/package.json +++ b/app/package.json @@ -7,6 +7,7 @@ "@kobsio/plugin-applications": "*", "@kobsio/plugin-core": "*", "@kobsio/plugin-dashboards": "*", + "@kobsio/plugin-prometheus": "*", "@kobsio/plugin-resources": "*", "@kobsio/plugin-teams": "*", "@types/node": "^15.14.0", diff --git a/app/src/index.tsx b/app/src/index.tsx index f0590a0dd..018b033a1 100644 --- a/app/src/index.tsx +++ b/app/src/index.tsx @@ -8,6 +8,7 @@ import resourcesPlugin from '@kobsio/plugin-resources'; import teamsPlugin from '@kobsio/plugin-teams'; import applicationsPlugin from '@kobsio/plugin-applications'; import dashboardsPlugin from '@kobsio/plugin-dashboards'; +import prometheusPlugin from '@kobsio/plugin-prometheus'; ReactDOM.render( @@ -16,6 +17,7 @@ ReactDOM.render( ...teamsPlugin, ...applicationsPlugin, ...dashboardsPlugin, + ...prometheusPlugin, }} /> , document.getElementById('root') diff --git a/deploy/docker/kobs/config.yaml b/deploy/docker/kobs/config.yaml index 543415319..749bc4c40 100644 --- a/deploy/docker/kobs/config.yaml +++ b/deploy/docker/kobs/config.yaml @@ -14,13 +14,14 @@ plugins: topologyCacheDuration: 1m teamsCacheDuration: 1m -prometheus: - - name: Prometheus - description: Prometheus can be used for the metrics of your application. - address: http://localhost:9090 - username: - password: - token: + prometheus: + - name: prometheus + displayName: Prometheus + description: Prometheus can be used for the metrics of your application. + address: http://localhost:9090 + username: + password: + token: elasticsearch: - name: Elasticsearch diff --git a/go.mod b/go.mod index ad0f7ee9a..a65cd3c72 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/go-chi/cors v1.2.0 github.com/go-chi/render v1.0.1 github.com/prometheus/client_golang v1.11.0 + github.com/prometheus/common v0.26.0 github.com/sirupsen/logrus v1.8.1 github.com/spf13/pflag v1.0.5 gopkg.in/yaml.v2 v2.4.0 diff --git a/go.sum b/go.sum index 06d8d692f..fc686e4ed 100644 --- a/go.sum +++ b/go.sum @@ -218,6 +218,7 @@ github.com/imdario/mergo v0.3.5 h1:JboBksRwiiAJWvIYJVo46AfV+IAIKZpfrSzVKj42R4Q= github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= @@ -273,6 +274,7 @@ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3Rllmb github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= diff --git a/pkg/api/middleware/roundtripper/roundtripper.go b/pkg/api/middleware/roundtripper/roundtripper.go new file mode 100644 index 000000000..602ff14c5 --- /dev/null +++ b/pkg/api/middleware/roundtripper/roundtripper.go @@ -0,0 +1,42 @@ +package roundtripper + +import ( + "net" + "net/http" + "time" +) + +// DefaultRoundTripper is our default RoundTripper. +var DefaultRoundTripper http.RoundTripper = &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + TLSHandshakeTimeout: 10 * time.Second, +} + +// BasicAuthTransport is the struct to add basic auth to a RoundTripper. +type BasicAuthTransport struct { + Transport http.RoundTripper + Username string + Password string +} + +// RoundTrip implements the RoundTrip for our RoundTripper with basic auth support. +func (bat BasicAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.SetBasicAuth(bat.Username, bat.Password) + return bat.Transport.RoundTrip(req) +} + +// TokenAuthTransporter is the struct to add token auth to a RoundTripper. +type TokenAuthTransporter struct { + Transport http.RoundTripper + Token string +} + +// RoundTrip implements the RoundTrip for our RoundTripper with token auth support. +func (tat TokenAuthTransporter) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Set("Authorization", "Bearer "+tat.Token) + return tat.Transport.RoundTrip(req) +} diff --git a/pkg/api/plugins/plugins.go b/pkg/api/plugins/plugins.go index 2a991e770..6b55cb131 100644 --- a/pkg/api/plugins/plugins.go +++ b/pkg/api/plugins/plugins.go @@ -13,6 +13,7 @@ import ( // the plugins folder. "github.com/kobsio/kobs/plugins/applications" "github.com/kobsio/kobs/plugins/dashboards" + "github.com/kobsio/kobs/plugins/prometheus" "github.com/kobsio/kobs/plugins/resources" "github.com/kobsio/kobs/plugins/teams" ) @@ -23,6 +24,7 @@ type Config struct { Resources resources.Config `yaml:"resources"` Teams teams.Config `yaml:"teams"` Dashboards dashboards.Config `yaml:"dashboards"` + Prometheus prometheus.Config `yaml:"prometheus"` } // Router implements the router for the plugins package. This only registeres one route which is used to return all the @@ -51,6 +53,7 @@ func Register(clusters *clusters.Clusters, config Config) chi.Router { router.Mount(resources.Route, resources.Register(clusters, router.plugins, config.Resources)) router.Mount(teams.Route, teams.Register(clusters, router.plugins, config.Teams)) router.Mount(dashboards.Route, dashboards.Register(clusters, router.plugins, config.Dashboards)) + router.Mount(prometheus.Route, prometheus.Register(clusters, router.plugins, config.Prometheus)) return router } diff --git a/plugins/applications/src/components/page/Application.tsx b/plugins/applications/src/components/page/Application.tsx index aeddffdf6..487595860 100644 --- a/plugins/applications/src/components/page/Application.tsx +++ b/plugins/applications/src/components/page/Application.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { UsersIcon } from '@patternfly/react-icons'; import { ExternalLink, Title } from '@kobsio/plugin-core'; -import { Dashboards } from '@kobsio/plugin-dashboards'; +import { DashboardsWrapper } from '@kobsio/plugin-dashboards'; import { IApplication } from '../../utils/interfaces'; interface IApplicationsParams { @@ -128,7 +128,7 @@ const Application: React.FunctionComponent = () => { {data.dashboards ? ( - + ) : ( )} diff --git a/plugins/applications/src/components/panel/details/Details.tsx b/plugins/applications/src/components/panel/details/Details.tsx index 52cdfcb92..989d46853 100644 --- a/plugins/applications/src/components/panel/details/Details.tsx +++ b/plugins/applications/src/components/panel/details/Details.tsx @@ -15,7 +15,7 @@ import React from 'react'; import { UsersIcon } from '@patternfly/react-icons'; import { ExternalLink, Title } from '@kobsio/plugin-core'; -import { Dashboards } from '@kobsio/plugin-dashboards'; +import { DashboardsWrapper } from '@kobsio/plugin-dashboards'; import DetailsLink from './DetailsLink'; import { IApplication } from '../../../utils/interfaces'; @@ -73,7 +73,7 @@ const Details: React.FunctionComponent = ({ application, close }: {application.dashboards ? ( - + ) : null}

 

diff --git a/plugins/core/src/components/misc/Options.tsx b/plugins/core/src/components/misc/Options.tsx index 38ae1b873..649b4c27f 100644 --- a/plugins/core/src/components/misc/Options.tsx +++ b/plugins/core/src/components/misc/Options.tsx @@ -47,6 +47,26 @@ export type TTime = | 'last7Days' | 'last90Days'; +// TTimeOptions is an array with all available type for TTime. It is used to verify that the value of a string is an +//valid option for the TTime type. +export const TTimeOptions = [ + 'custom', + 'last12Hours', + 'last15Minutes', + 'last1Day', + 'last1Hour', + 'last1Year', + 'last2Days', + 'last30Days', + 'last30Minutes', + 'last3Hours', + 'last5Minutes', + 'last6Hours', + 'last6Months', + 'last7Days', + 'last90Days', +]; + // ITime is the interface for a time in the times map. It contains a label which should be displayed in the Options // component and the seconds between the start and the end time. interface ITime { @@ -88,6 +108,7 @@ interface IOptionsProps { timeEnd: number; timeStart: number; setOptions: ( + refresh: boolean, additionalFields: IOptionsAdditionalFields[] | undefined, time: TTime, timeEnd: number, @@ -142,6 +163,7 @@ export const Options: React.FunctionComponent = ({ setCustomTimeStartError(''); setCustomTimeEndError(''); setOptions( + false, additionalFields, 'custom', Math.floor(parsedTimeEnd.getTime() / 1000), @@ -154,7 +176,13 @@ export const Options: React.FunctionComponent = ({ // 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 = (t: TTime): void => { - setOptions(additionalFields, t, Math.floor(Date.now() / 1000), Math.floor(Date.now() / 1000) - times[t].seconds); + setOptions( + false, + additionalFields, + t, + Math.floor(Date.now() / 1000), + Math.floor(Date.now() / 1000) - times[t].seconds, + ); setShow(false); }; @@ -172,6 +200,7 @@ export const Options: React.FunctionComponent = ({ const refreshTimes = (): void => { if (time !== 'custom') { setOptions( + true, additionalFields, time, Math.floor(Date.now() / 1000), diff --git a/plugins/core/src/components/plugin/PluginCard.tsx b/plugins/core/src/components/plugin/PluginCard.tsx index 4d882fbe5..6ef4df25a 100644 --- a/plugins/core/src/components/plugin/PluginCard.tsx +++ b/plugins/core/src/components/plugin/PluginCard.tsx @@ -5,7 +5,7 @@ interface IPluginCardProps { title: string; description?: string; transparent?: boolean; - children: React.ReactElement; + children: React.ReactElement | null; actions?: React.ReactElement; } diff --git a/plugins/core/src/index.ts b/plugins/core/src/index.ts index 93e6da75b..3fa0be63b 100644 --- a/plugins/core/src/index.ts +++ b/plugins/core/src/index.ts @@ -17,5 +17,7 @@ export * from './context/PluginsContext'; export * from './utils/manifests'; export * from './utils/resources'; export * from './utils/time'; +export * from './utils/useDebounce'; +export * from './utils/useDimensions'; export * from './utils/useWindowHeight'; export * from './utils/useWindowWidth'; diff --git a/plugins/core/src/utils/useDebounce.tsx b/plugins/core/src/utils/useDebounce.tsx new file mode 100644 index 000000000..883184abe --- /dev/null +++ b/plugins/core/src/utils/useDebounce.tsx @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react'; + +export const useDebounce = (value: T, delay: number): T => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return (): void => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +}; diff --git a/plugins/core/src/utils/useDimensions.tsx b/plugins/core/src/utils/useDimensions.tsx new file mode 100644 index 000000000..ccfb834b2 --- /dev/null +++ b/plugins/core/src/utils/useDimensions.tsx @@ -0,0 +1,34 @@ +import { useEffect, useLayoutEffect, useState } from 'react'; + +export interface IDimensions { + height: number; + width: number; +} + +export const useDimensions = (targetRef: React.RefObject): IDimensions => { + const getDimensions = (): IDimensions => { + return { + height: targetRef.current ? targetRef.current.offsetHeight : 0, + width: targetRef.current ? targetRef.current.offsetWidth : 0, + }; + }; + + const [dimensions, setDimensions] = useState(getDimensions); + + const handleResize = (): void => { + setDimensions(getDimensions()); + }; + + useEffect(() => { + window.addEventListener('resize', handleResize); + return (): void => window.removeEventListener('resize', handleResize); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useLayoutEffect(() => { + handleResize(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return dimensions; +}; diff --git a/plugins/dashboards/src/components/dashboards/Dashboard.tsx b/plugins/dashboards/src/components/dashboards/Dashboard.tsx index e94fd0e95..3835ca1e8 100644 --- a/plugins/dashboards/src/components/dashboards/Dashboard.tsx +++ b/plugins/dashboards/src/components/dashboards/Dashboard.tsx @@ -60,8 +60,12 @@ const Dashboard: React.FunctionComponent = ({ // that all child components should used the retunred data array instead of the formerly defined variables state, // because it contains the current values and selected value for a variable. The variables state is mainly there to // trigger this function everytime a new variable value is selected. + // Currently we support "core" variable, which can be used to select a cluster or plugin and we are supporting the + // Prometheus plugin. For the Prometheus plugin the user must specify the name of the Prometheus instance via the name + // parameter in the options. When the user changes the variables, we keep the old variable values, so that we not have + // to rerender all the panels in the dashboard. const { isError, error, data, refetch } = useQuery( - ['dashboard/variables', variables], + ['dashboard/variables', variables, times], async () => { if (!variables) { return []; @@ -86,6 +90,41 @@ const Dashboard: React.FunctionComponent = ({ } } } + } else { + const pluginDetails = pluginsContext.getPluginDetails(tmpVariables[i].plugin.name); + + if (pluginDetails?.type === 'prometheus') { + const response = await fetch(`/api/plugins/prometheus/variable/${tmpVariables[i].plugin.name}`, { + body: JSON.stringify({ + label: tmpVariables[i].plugin.options.label, + query: interpolate(tmpVariables[i].plugin.options.query, tmpVariables), + timeEnd: times.timeEnd, + timeStart: times.timeStart, + type: tmpVariables[i].plugin.options.type, + }), + method: 'post', + }); + const json = await response.json(); + + if (response.status >= 200 && response.status < 300) { + if (json && Array.isArray(json) && json.length > 0) { + if (tmpVariables[i].plugin.options.allowAll) { + json.unshift(json.join('|')); + } + + tmpVariables[i].values = json; + tmpVariables[i].value = json.includes(tmpVariables[i].value) ? tmpVariables[i].value : json[0]; + } else { + throw new Error(`No values for variable ${tmpVariables[i].label || tmpVariables[i].name}`); + } + } else { + if (json.error) { + throw new Error(json.error); + } else { + throw new Error('An unknown error occured'); + } + } + } } } @@ -94,6 +133,7 @@ const Dashboard: React.FunctionComponent = ({ throw err; } }, + { keepPreviousData: true }, ); // We do not use the dashboard.rows array directly to render the dashboard. Instead we are replacing all the variables @@ -126,6 +166,7 @@ const Dashboard: React.FunctionComponent = ({ ); } + return ( diff --git a/plugins/dashboards/src/components/dashboards/DashboardToolbar.tsx b/plugins/dashboards/src/components/dashboards/DashboardToolbar.tsx index 1ae27fbf3..75dd0f1da 100644 --- a/plugins/dashboards/src/components/dashboards/DashboardToolbar.tsx +++ b/plugins/dashboards/src/components/dashboards/DashboardToolbar.tsx @@ -52,6 +52,7 @@ const DashboardToolbar: React.FunctionComponent = ({ timeEnd={times.timeEnd} timeStart={times.timeStart} setOptions={( + refresh: boolean, additionalFields: IOptionsAdditionalFields[] | undefined, time: TTime, timeEnd: number, diff --git a/plugins/dashboards/src/components/dashboards/DashboardToolbarVariable.tsx b/plugins/dashboards/src/components/dashboards/DashboardToolbarVariable.tsx index be032ec3c..6c67caf91 100644 --- a/plugins/dashboards/src/components/dashboards/DashboardToolbarVariable.tsx +++ b/plugins/dashboards/src/components/dashboards/DashboardToolbarVariable.tsx @@ -46,6 +46,7 @@ const DashboardToolbarVariable: React.FunctionComponent {group} diff --git a/plugins/dashboards/src/components/dashboards/DashboardWrapper.tsx b/plugins/dashboards/src/components/dashboards/DashboardWrapper.tsx deleted file mode 100644 index a68836fcd..000000000 --- a/plugins/dashboards/src/components/dashboards/DashboardWrapper.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React, { memo, useEffect, useRef, useState } from 'react'; - -import { IPluginDefaults, useWindowWidth } from '@kobsio/plugin-core'; -import Dashboard from './Dashboard'; -import { IDashboard } from '../../utils/interfaces'; - -interface IDashboardWrapperProps { - defaults: IPluginDefaults; - dashboard: IDashboard; - showDetails?: (details: React.ReactNode) => void; -} - -// The DashboardWrapper component is a wrapper for the Dashboard component to determine the width of the dashboard. This -// is required so we can force the default span width for smaller screens. This boolean value is passed as property to -// the Dashboard component. -const DashboardWrapper: React.FunctionComponent = ({ - defaults, - dashboard, - showDetails, -}: IDashboardWrapperProps) => { - // width, refGrid and forceDefaultSpan are used to determine the current width of the dashboard (this isn't always the - // screen width, because the dashboard can also be used in a panel), so that we can adjust the size of the rows and - // columns in the grid. - const width = useWindowWidth(); - const refGrid = useRef(null); - const [forceDefaultSpan, setForceDefaultSpan] = useState(false); - - // useEffect is executed every time the window width changes, to determin the size of the grid and use a static span - // size of 12 if necessary. We have to use the with of the grid instead of the window width, because it is possible - // that the chart is rendered in a drawer (e.g. for applications in the applications page). - useEffect(() => { - if (refGrid && refGrid.current) { - if (refGrid.current.getBoundingClientRect().width >= 1200) { - setForceDefaultSpan(false); - } else { - setForceDefaultSpan(true); - } - } - }, [width]); - - return ( -
- -
- ); -}; - -export default memo(DashboardWrapper, (prevProps, nextProps) => { - if (JSON.stringify(prevProps) === JSON.stringify(nextProps)) { - return true; - } - - return false; -}); diff --git a/plugins/dashboards/src/components/dashboards/Dashboards.tsx b/plugins/dashboards/src/components/dashboards/Dashboards.tsx index c76487cf7..efe101992 100644 --- a/plugins/dashboards/src/components/dashboards/Dashboards.tsx +++ b/plugins/dashboards/src/components/dashboards/Dashboards.tsx @@ -14,29 +14,57 @@ import { Tabs, } from '@patternfly/react-core'; import { QueryObserverResult, useQuery } from 'react-query'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; +import { useHistory, useLocation } from 'react-router-dom'; -import { IDashboard, IReference } from '../../utils/interfaces'; -import DashboardWrapper from './DashboardWrapper'; +import { IDashboard, IDashboardsOptions, IReference } from '../../utils/interfaces'; +import Dashboard from './Dashboard'; import { IPluginDefaults } from '@kobsio/plugin-core'; +import { getOptionsFromSearch } from '../../utils/dashboard'; interface IDashboardsProps { defaults: IPluginDefaults; references: IReference[]; useDrawer: boolean; + forceDefaultSpan: boolean; } // The Dashboards component is used to fetch all the referenced dashboards in a team/application and show them as tabs. // The useDrawer property is used to decide if the dashboard should be used inside a drawer or not. For example if an // application is already displayed in a drawer we shouldn't use another drawer for the dashboards. -export const Dashboards: React.FunctionComponent = ({ +const Dashboards: React.FunctionComponent = ({ defaults, references, useDrawer, + forceDefaultSpan, }: IDashboardsProps) => { - const [activeDashboard, setActiveDashboard] = useState(references.length > 0 ? references[0].title : ''); + const location = useLocation(); + const history = useHistory(); + const [options, setOptions] = useState( + getOptionsFromSearch(location.search, references, useDrawer), + ); const [details, setDetails] = useState(undefined); + // changeOptions adjusts the search location (query paramters). We do not set the options directly (except when the + // Dashboards component is rendered inside a drawer), so that a user can share the url and a other users gets the same + // view. + const changeOptions = (opts: IDashboardsOptions): void => { + history.push({ + pathname: location.pathname, + search: `?dashboard=${opts.dashboard}`, + }); + }; + + // useEffect is used to set the options for the dashboards, everytime the query paramters for the current page are + // changed. This is only used, when the dashboards are not rendered in a drawer. When the dashboards are rendered in + // a drawer it can happen that we already show some dashboards in the main view and so we can not rely on the query + // parameters. + useEffect(() => { + if (useDrawer) { + setOptions(getOptionsFromSearch(location.search, references, useDrawer)); + } + }, [location.search, references, useDrawer]); + // Fetch all dashboards. The dashboards are available via the data variable. To fetch the dashboards we have to pass // the defaults and the references to the API. The defaults are required so that a user can omit the cluster and // namespace in the references. @@ -107,8 +135,12 @@ export const Dashboards: React.FunctionComponent = ({ // variable is true we render the tabs as content inside the drawer component. const tabs = ( setActiveDashboard(tabIndex.toString())} + activeKey={options.dashboard} + onSelect={(event, tabIndex): void => + useDrawer + ? changeOptions({ ...options, dashboard: tabIndex.toString() }) + : setOptions({ ...options, dashboard: tabIndex.toString() }) + } className="pf-u-mt-md kobsio-dashboards-tabs-without-margin-top" isFilled={true} mountOnEnter={true} @@ -116,9 +148,10 @@ export const Dashboards: React.FunctionComponent = ({ {data.map((dashboard) => ( {dashboard.title}}> - @@ -139,3 +172,5 @@ export const Dashboards: React.FunctionComponent = ({ ); }; + +export default Dashboards; diff --git a/plugins/dashboards/src/components/dashboards/DashboardsWrapper.tsx b/plugins/dashboards/src/components/dashboards/DashboardsWrapper.tsx new file mode 100644 index 000000000..f0c6bcbce --- /dev/null +++ b/plugins/dashboards/src/components/dashboards/DashboardsWrapper.tsx @@ -0,0 +1,36 @@ +import React, { useRef } from 'react'; + +import { IPluginDefaults, useDimensions } from '@kobsio/plugin-core'; +import Dashboards from './Dashboards'; +import { IReference } from '../../utils/interfaces'; + +interface IDashboardsWrapperProps { + defaults: IPluginDefaults; + references: IReference[]; + useDrawer: boolean; +} + +// The DashboardsWrapper component is a wrapper for the Dashboards component. It is used to determine the dimensions for +// the dashboards. This is needed, so that we can force the default value for the grid span, when the space were the +// dashboards are rendered is to small. We can not use the window width for this, because when the dashboards are +// rendered in a drawer (e.g. application details) the windows width would be incorrect. +// The DashboardsWrapper component should always used in other component. Never use the Dashboards component directly! +export const DashboardsWrapper: React.FunctionComponent = ({ + defaults, + references, + useDrawer, +}: IDashboardsWrapperProps) => { + const refTabs = useRef(null); + const tabsSize = useDimensions(refTabs); + + return ( +
+ +
+ ); +}; diff --git a/plugins/dashboards/src/index.ts b/plugins/dashboards/src/index.ts index 5f89114fb..e0afe4d5c 100644 --- a/plugins/dashboards/src/index.ts +++ b/plugins/dashboards/src/index.ts @@ -18,5 +18,5 @@ const dashboardsPlugin: IPluginComponents = { export default dashboardsPlugin; -export * from './components/dashboards/Dashboards'; +export * from './components/dashboards/DashboardsWrapper'; export type IDashboardReference = IReference; diff --git a/plugins/dashboards/src/utils/dashboard.ts b/plugins/dashboards/src/utils/dashboard.ts index 8ae5631d0..cacb7b9fe 100644 --- a/plugins/dashboards/src/utils/dashboard.ts +++ b/plugins/dashboards/src/utils/dashboard.ts @@ -1,6 +1,6 @@ import { gridSpans } from '@patternfly/react-core'; -import { IVariableValues } from './interfaces'; +import { IDashboardsOptions, IReference, IVariableValues } from './interfaces'; // toGridSpans is used to convert the provided col and row span value to the corresponding gridSpans value, so that it // can be used within the Patternfly Grid component. The function requires a default value which is 12 for columns and @@ -69,10 +69,35 @@ export const interpolate = ( s2[0] = s2[0] && vars.hasOwnProperty(s2[0].trim().substring(1)) ? vars[s2[0].trim().substring(1)] - : interpolator.join(''); + : interpolator.join(` ${s2[0]} `); } return s2.join(''); }) .join(''); }; + +// getOptionsFromSearch is used to parse the given search location and return is as options for Prometheus. This is +// needed, so that a user can explore his Prometheus data from a chart. When the user selects the explore action, we +// pass him to this page and pass the data via the URL parameters. +export const getOptionsFromSearch = ( + search: string, + references: IReference[], + useDrawer: boolean, +): IDashboardsOptions => { + const params = new URLSearchParams(search); + const dashboard = params.get('dashboard'); + + let isReferenced = false; + if (useDrawer) { + for (const reference of references) { + if (reference.title === dashboard) { + isReferenced = true; + } + } + } + + return { + dashboard: dashboard && isReferenced ? dashboard : references.length > 0 ? references[0].title : '', + }; +}; diff --git a/plugins/dashboards/src/utils/interfaces.ts b/plugins/dashboards/src/utils/interfaces.ts index 0ee1e60ab..99bba877c 100644 --- a/plugins/dashboards/src/utils/interfaces.ts +++ b/plugins/dashboards/src/utils/interfaces.ts @@ -1,3 +1,6 @@ +// IDashboard is the interface for the Dashboards CR, like it is implemented in the Go code. In contrast to the Go +// implementation we are sure that the cluster, namespace and name for the dashboard is set, because the values are set +// each time a dashboard is retrieved from the Kubernetes API. export interface IDashboard { cluster: string; namespace: string; @@ -42,6 +45,8 @@ export interface IPlugin { options?: any; } +// IReference is the interface for a dashboard reference in the Team or Application CR. If the cluster or namespace is +// not specified in the reference we assume the dashboard is in the same namespace as the team or application. export interface IReference { cluster?: string; namespace?: string; @@ -55,7 +60,16 @@ export interface IPlaceholders { [key: string]: string; } +// IVariableValues is an extension of the IVariable interface. It contains the additional fields for the selected +// variable value and all possible variable values. export interface IVariableValues extends IVariable { value: string; values: string[]; } + +// IDashboardsOptions are the options for the Dashboards component. Currently is only contains the active dashboard, but +// we can extend this later to also include the selected time and variables, so that we can pass this values with the +// current url as query parameters. +export interface IDashboardsOptions { + dashboard: string; +} diff --git a/plugins/prometheus/package.json b/plugins/prometheus/package.json new file mode 100644 index 000000000..38a064ca1 --- /dev/null +++ b/plugins/prometheus/package.json @@ -0,0 +1,28 @@ +{ + "name": "@kobsio/plugin-prometheus", + "version": "0.0.0", + "license": "MIT", + "private": false, + "main": "./lib/index.js", + "module": "./lib-esm/index.js", + "types": "./lib/index.d.ts", + "scripts": { + "plugin": "tsc && tsc --build tsconfig.esm.json && cp -r src/assets lib && cp -r src/assets lib-esm" + }, + "devDependencies": { + "@kobsio/plugin-core": "*", + "@nivo/core": "^0.72.0", + "@nivo/line": "^0.72.0", + "@patternfly/react-core": "^4.128.2", + "@patternfly/react-icons": "^4.11.0", + "@patternfly/react-table": "^4.29.0", + "@types/react": "^17.0.0", + "@types/react-dom": "^17.0.0", + "@types/react-router-dom": "^5.1.7", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-query": "^3.17.2", + "react-router-dom": "^5.2.0", + "typescript": "^4.3.4" + } +} diff --git a/plugins/prometheus/pkg/instance/helpers.go b/plugins/prometheus/pkg/instance/helpers.go new file mode 100644 index 000000000..881587fd8 --- /dev/null +++ b/plugins/prometheus/pkg/instance/helpers.go @@ -0,0 +1,54 @@ +package instance + +import ( + "bytes" + "text/template" + "time" +) + +// appendIfMissing appends a value to a slice, when this values doesn't exist in the slice already. +func appendIfMissing(items []string, item string) []string { + for _, ele := range items { + if ele == item { + return items + } + } + + return append(items, item) +} + +// queryInterpolation is used to replace variables in a query. +func queryInterpolation(query string, variables map[string]string) (string, error) { + tpl, err := template.New("query").Delims("{%", "%}").Parse(query) + if err != nil { + return "", err + } + + var buf bytes.Buffer + err = tpl.Execute(&buf, variables) + if err != nil { + return "", err + } + + return buf.String(), nil +} + +// getSteps returns the duration for the Prometheus resolution for a given start and end time. +func getSteps(start, end int64) time.Duration { + switch seconds := end - start; { + case seconds <= 6*3600: + return time.Duration(30 * time.Second) + case seconds <= 12*3600: + return time.Duration(60 * time.Second) + case seconds <= 24*3600: + return time.Duration(120 * time.Second) + case seconds <= 2*24*3600: + return time.Duration(300 * time.Second) + case seconds <= 7*24*3600: + return time.Duration(1800 * time.Second) + case seconds <= 30*24*3600: + return time.Duration(3600 * time.Second) + default: + return time.Duration((end-start)/1000) * time.Second + } +} diff --git a/plugins/prometheus/pkg/instance/instance.go b/plugins/prometheus/pkg/instance/instance.go new file mode 100644 index 000000000..e633f7d4f --- /dev/null +++ b/plugins/prometheus/pkg/instance/instance.go @@ -0,0 +1,286 @@ +package instance + +import ( + "context" + "fmt" + "math" + "sort" + "strings" + "time" + + "github.com/kobsio/kobs/pkg/api/middleware/roundtripper" + + "github.com/prometheus/client_golang/api" + v1 "github.com/prometheus/client_golang/api/prometheus/v1" + "github.com/prometheus/common/model" + "github.com/sirupsen/logrus" +) + +var ( + log = logrus.WithFields(logrus.Fields{"package": "prometheus"}) +) + +// Config is the structure of the configuration for a single Prometheus instance. +type Config struct { + Name string `yaml:"name"` + DisplayName string `yaml:"displayName"` + Description string `yaml:"description"` + Address string `yaml:"address"` + Username string `yaml:"username"` + Password string `yaml:"password"` + Token string `yaml:"token"` +} + +// Instance represents a single Prometheus instance, which can be added via the configuration file. +type Instance struct { + Name string + labelValues model.LabelValues + labelValuesLastFetch time.Time + v1api v1.API +} + +// GetVariable returns all values for a label from the given query. For that we have to retrive the label sets from the +// Prometheus instance and so that we can add the values for the specified label to the values slice. +func (i *Instance) GetVariable(ctx context.Context, label, query, queryType string, timeStart, timeEnd int64) ([]string, error) { + log.WithFields(logrus.Fields{"query": query}).Tracef("Query variable values") + + labelSets, _, err := i.v1api.Series(ctx, []string{query}, time.Unix(timeStart, 0), time.Unix(timeEnd, 0)) + if err != nil { + return nil, err + } + + var values []string + for _, labelSet := range labelSets { + if value, ok := labelSet[model.LabelName(label)]; ok { + values = appendIfMissing(values, string(value)) + } + } + + sort.Strings(values) + + return values, nil +} + +// GetMetrics returns all metrics for all given queries. For each given query we have to make one call to the Prometheus +// API. Then we have to loop through the returned time series and transform them into a format, which can be processed +// by out React UI. +func (i *Instance) GetMetrics(ctx context.Context, queries []Query, resolution string, timeStart, timeEnd int64) ([]Metric, error) { + steps := getSteps(timeStart, timeEnd) + if resolution != "" { + parsedDuration, err := time.ParseDuration(resolution) + if err == nil { + steps = parsedDuration + } + } + + r := v1.Range{ + Start: time.Unix(timeStart, 0), + End: time.Unix(timeEnd, 0), + Step: steps, + } + + var metrics []Metric + + for queryIndex, query := range queries { + log.WithFields(logrus.Fields{"query": query.Query, "label": query.Label, "resolution": resolution, "start": r.Start, "end": r.End}).Tracef("Query time series") + + result, _, err := i.v1api.QueryRange(ctx, query.Query, r) + if err != nil { + return nil, err + } + + streams, ok := result.(model.Matrix) + if !ok { + return nil, err + } + + for streamIndex, stream := range streams { + var min float64 + var max float64 + var avg float64 + + var data []Datum + for index, value := range stream.Values { + val := float64(value.Value) + + if math.IsNaN(val) { + data = append(data, Datum{ + X: value.Timestamp.Unix() * 1000, + }) + } else { + avg = avg + val + + if index == 0 { + min = val + max = val + } else { + if val < min { + min = val + } else if val > max { + max = val + } + } + + data = append(data, Datum{ + X: value.Timestamp.Unix() * 1000, + Y: &val, + }) + } + } + + if avg != 0 { + avg = avg / float64(len(stream.Values)) + } + + var labels map[string]string + labels = make(map[string]string) + + for key, value := range stream.Metric { + labels[string(key)] = string(value) + } + + label, err := queryInterpolation(query.Label, labels) + if err != nil { + metrics = append(metrics, Metric{ + ID: fmt.Sprintf("%d-%d", queryIndex, streamIndex), + Label: query.Label, + Min: min, + Max: max, + Avg: avg, + Data: data, + }) + } else { + if label == "" { + label = stream.Metric.String() + } + + metrics = append(metrics, Metric{ + ID: fmt.Sprintf("%d-%d", queryIndex, streamIndex), + Label: label, + Min: min, + Max: max, + Avg: avg, + Data: data, + }) + } + } + } + + return metrics, nil +} + +// GetTableData returns the data, when the user selected the table view for the Prometheus plugin. To get the data we +// are running all prodived queries and join the results by the value for a label of a query. The value for a query is +// added as value-N column. We are also adding all other labels as fields to a single row. +func (i *Instance) GetTableData(ctx context.Context, queries []Query, timeEnd int64) (map[string]map[string]string, error) { + queryTime := time.Unix(timeEnd, 0) + + var rows map[string]map[string]string + rows = make(map[string]map[string]string) + + for queryIndex, query := range queries { + log.WithFields(logrus.Fields{"query": query, "time": queryTime}).Tracef("Query table data") + + result, _, err := i.v1api.Query(ctx, query.Query, queryTime) + if err != nil { + return nil, err + } + + streams, ok := result.(model.Vector) + if !ok { + return nil, err + } + + for _, stream := range streams { + var labels map[string]string + labels = make(map[string]string) + labels[fmt.Sprintf("value-%d", queryIndex+1)] = stream.Value.String() + + for key, value := range stream.Metric { + labels[string(key)] = string(value) + } + + label, err := queryInterpolation(query.Label, labels) + if err != nil { + return nil, err + } + + for key, value := range labels { + if _, ok := rows[label]; !ok { + rows[label] = make(map[string]string) + } + + if _, ok := rows[label][key]; !ok { + rows[label][key] = value + } + + rows[label][fmt.Sprintf("value-%d", queryIndex+1)] = stream.Value.String() + } + } + } + + return rows, nil +} + +// GetLabelValues returns all label values for a configured Prometheus instance. These labels are used to show the user +// a list of suggestions for his entered query. The returned label values from the Prometheus API are cached for one +// hour. +func (i *Instance) GetLabelValues(ctx context.Context, searchTerm string) ([]string, error) { + now := time.Now() + + // Fetch metric names if last fetch was more then a minute ago + if i.labelValuesLastFetch.Add(1 * time.Hour).Before(now) { + labelValues, _, err := i.v1api.LabelValues(ctx, model.MetricNameLabel, nil, now.Add(-1*time.Hour), now) + if err != nil { + return nil, err + } + + i.labelValues = labelValues + i.labelValuesLastFetch = now + log.WithFields(logrus.Fields{"instance": i.Name}).Tracef("Get metric names.") + } else { + log.WithFields(logrus.Fields{"instance": i.Name}).Tracef("Use cached metric names.") + } + + var names []string + for _, name := range i.labelValues { + if strings.Contains(string(name), searchTerm) { + names = append(names, string(name)) + } + } + + return names, nil +} + +// New returns a new Prometheus instance for the given configuration. +func New(config Config) (*Instance, error) { + roundTripper := roundtripper.DefaultRoundTripper + + if config.Username != "" && config.Password != "" { + roundTripper = roundtripper.BasicAuthTransport{ + Transport: roundTripper, + Username: config.Username, + Password: config.Password, + } + } + + if config.Token != "" { + roundTripper = roundtripper.TokenAuthTransporter{ + Transport: roundTripper, + Token: config.Token, + } + } + + client, err := api.NewClient(api.Config{ + Address: config.Address, + RoundTripper: roundTripper, + }) + if err != nil { + return nil, err + } + + return &Instance{ + Name: config.Name, + v1api: v1.NewAPI(client), + }, nil +} diff --git a/plugins/prometheus/pkg/instance/structs.go b/plugins/prometheus/pkg/instance/structs.go new file mode 100644 index 000000000..c0d545463 --- /dev/null +++ b/plugins/prometheus/pkg/instance/structs.go @@ -0,0 +1,27 @@ +package instance + +// Query is the structure of a query. Each query consists of the PromQL query and an optional label, which can be +// displayed in the legend of a chart. +type Query struct { + Query string `json:"query"` + Label string `json:"label"` +} + +// Metric is the response format for a single metric. Each metric must have an ID and label. We also add the min, max +// and average for the returned data. +type Metric struct { + ID string `json:"id"` + Label string `json:"label"` + Min float64 `json:"min"` + Max float64 `json:"max"` + Avg float64 `json:"avg"` + Data []Datum `json:"data"` +} + +// Datum is the structure of a single data point of a metric. The y value must be a pointer, because when the value is +// NaN we can not set the value (NaN in the JSON representation will throw an error). For that NaN values will always be +// null. +type Datum struct { + X int64 `json:"x"` + Y *float64 `json:"y"` +} diff --git a/plugins/prometheus/prometheus.go b/plugins/prometheus/prometheus.go new file mode 100644 index 000000000..87f8a61a0 --- /dev/null +++ b/plugins/prometheus/prometheus.go @@ -0,0 +1,216 @@ +package prometheus + +import ( + "encoding/json" + "net/http" + + "github.com/kobsio/kobs/pkg/api/clusters" + "github.com/kobsio/kobs/pkg/api/middleware/errresponse" + "github.com/kobsio/kobs/pkg/api/plugins/plugin" + "github.com/kobsio/kobs/plugins/prometheus/pkg/instance" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + "github.com/sirupsen/logrus" +) + +// Route is the route under which the plugin should be registered in our router for the rest api. +const Route = "/prometheus" + +var ( + log = logrus.WithFields(logrus.Fields{"package": "prometheus"}) +) + +// Config is the structure of the configuration for the prometheus plugin. +type Config []instance.Config + +// Router implements the router for the resources plugin, which can be registered in the router for our rest api. +type Router struct { + *chi.Mux + clusters *clusters.Clusters + instances []*instance.Instance +} + +// getVariableRequest +type getVariableRequest struct { + Label string `json:"label"` + Query string `json:"query"` + TimeStart int64 `json:"timeStart"` + TimeEnd int64 `json:"timeEnd"` + Type string `json:"type"` +} + +// getMetricsRequest is the format of the request body for the getMetrics request. To get metrics from a Prometheus +// instance we need at least one query and the start and end time. Optionally the user can also set a resolution for the +// metrics to overwrite the default one. +type getMetricsRequest struct { + Queries []instance.Query `json:"queries"` + Resolution string `json:"Resolution"` + TimeStart int64 `json:"timeStart"` + TimeEnd int64 `json:"timeEnd"` +} + +func (router *Router) getInstance(name string) *instance.Instance { + for _, i := range router.instances { + if i.Name == name { + return i + } + } + + return nil +} + +// getVariable returns a list of variable values for a given label and query. The query and label are provided in the +// request body. The body also contains the type which should be used to get determine the label values and the start +// and end time. The Prometheus instance which should be used is defined via the name path parameter. All values are +// then passed to this instance via the GetVariable method. +func (router *Router) getVariable(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + + log.WithFields(logrus.Fields{"name": name}).Tracef("getVariables") + + i := router.getInstance(name) + if i == nil { + render.Render(w, r, errresponse.Render(nil, http.StatusBadRequest, "could not find instance name")) + return + } + + var data getVariableRequest + + err := json.NewDecoder(r.Body).Decode(&data) + if err != nil { + render.Render(w, r, errresponse.Render(err, http.StatusBadRequest, "could not decode request body")) + return + } + + values, err := i.GetVariable(r.Context(), data.Label, data.Query, data.Type, data.TimeStart, data.TimeEnd) + if err != nil { + render.Render(w, r, errresponse.Render(err, http.StatusBadRequest, "could not get variable")) + return + } + + log.WithFields(logrus.Fields{"values": len(values)}).Tracef("getVariables") + render.JSON(w, r, values) +} + +// getMetrics returns a list of metrics for the queries specified in the request body. To get the metrics we have to +// select the correct Prometheus instance, by the name path paramter. After that we can use the GetMetrics function of +// the instance to get a list of metrics. +func (router *Router) getMetrics(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + + log.WithFields(logrus.Fields{"name": name}).Tracef("getMetrics") + + i := router.getInstance(name) + if i == nil { + render.Render(w, r, errresponse.Render(nil, http.StatusBadRequest, "could not find instance name")) + return + } + + var data getMetricsRequest + + err := json.NewDecoder(r.Body).Decode(&data) + if err != nil { + render.Render(w, r, errresponse.Render(err, http.StatusBadRequest, "could not decode request body")) + return + } + + metrics, err := i.GetMetrics(r.Context(), data.Queries, data.Resolution, data.TimeStart, data.TimeEnd) + if err != nil { + render.Render(w, r, errresponse.Render(err, http.StatusBadRequest, "could not get metrics")) + return + } + + log.WithFields(logrus.Fields{"metrics": len(metrics)}).Tracef("getMetrics") + render.JSON(w, r, metrics) +} + +// getTable returns a table for a list of given Prometheus queries. The name of the Prometheus instance is set as path +// parameter. After we got the Prometheus instance, we are calling the GetTableData function of this instance to get a +// map of rows, which is then returned to the user. +func (router *Router) getTable(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + + log.WithFields(logrus.Fields{"name": name}).Tracef("getTable") + + i := router.getInstance(name) + if i == nil { + render.Render(w, r, errresponse.Render(nil, http.StatusBadRequest, "could not find instance name")) + return + } + + var data getMetricsRequest + + err := json.NewDecoder(r.Body).Decode(&data) + if err != nil { + render.Render(w, r, errresponse.Render(err, http.StatusBadRequest, "could not decode request body")) + return + } + + rows, err := i.GetTableData(r.Context(), data.Queries, data.TimeEnd) + if err != nil { + render.Render(w, r, errresponse.Render(err, http.StatusBadRequest, "could not get metrics")) + return + } + + log.WithFields(logrus.Fields{"rows": len(rows)}).Tracef("getTable") + render.JSON(w, r, rows) +} + +// getLabels returns a list of label values for the given searchTearm. The Prometheus instance is selected by the name +// path parameter. +func (router *Router) getLabels(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + searchTerm := r.URL.Query().Get("searchTerm") + + log.WithFields(logrus.Fields{"name": name, "searchTerm": searchTerm}).Tracef("getLabels") + + i := router.getInstance(name) + if i == nil { + render.Render(w, r, errresponse.Render(nil, http.StatusBadRequest, "could not find instance name")) + return + } + + labelValues, err := i.GetLabelValues(r.Context(), searchTerm) + if err != nil { + render.Render(w, r, errresponse.Render(err, http.StatusInternalServerError, "could not get label values")) + return + } + + log.WithFields(logrus.Fields{"labelValues": len(labelValues)}).Tracef("getLabels") + render.JSON(w, r, labelValues) +} + +// Register returns a new router which can be used in the router for the kobs rest api. +func Register(clusters *clusters.Clusters, plugins *plugin.Plugins, config Config) chi.Router { + var instances []*instance.Instance + + for _, cfg := range config { + instance, err := instance.New(cfg) + if err != nil { + log.WithError(err).WithFields(logrus.Fields{"name": cfg.Name}).Fatalf("Could not create Prometheus instance") + } + + instances = append(instances, instance) + + plugins.Append(plugin.Plugin{ + Name: cfg.Name, + DisplayName: cfg.DisplayName, + Description: cfg.Description, + Type: "prometheus", + }) + } + + router := Router{ + chi.NewRouter(), + clusters, + instances, + } + + router.Post("/variable/{name}", router.getVariable) + router.Post("/metrics/{name}", router.getMetrics) + router.Post("/table/{name}", router.getTable) + router.Get("/labels/{name}", router.getLabels) + + return router +} diff --git a/plugins/prometheus/src/assets/icon.png b/plugins/prometheus/src/assets/icon.png new file mode 100644 index 000000000..5e3b97c75 Binary files /dev/null and b/plugins/prometheus/src/assets/icon.png differ diff --git a/plugins/prometheus/src/components/page/Page.tsx b/plugins/prometheus/src/components/page/Page.tsx new file mode 100644 index 000000000..530277ea3 --- /dev/null +++ b/plugins/prometheus/src/components/page/Page.tsx @@ -0,0 +1,60 @@ +import { PageSection, PageSectionVariants, Title } from '@patternfly/react-core'; +import React, { useEffect, useState } from 'react'; +import { useHistory, useLocation } from 'react-router-dom'; + +import { IOptions } from '../../utils/interfaces'; +import { IPluginPageProps } from '@kobsio/plugin-core'; +import PageChart from './PageChartWrapper'; +import PageToolbar from './PageToolbar'; +import { getOptionsFromSearch } from '../../utils/helpers'; + +const Page: React.FunctionComponent = ({ name, displayName, description }: IPluginPageProps) => { + const location = useLocation(); + const history = useHistory(); + 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: IOptions): void => { + const queries = opts.queries ? opts.queries.map((query) => `&query=${query}`) : []; + + history.push({ + pathname: location.pathname, + search: `?resolution=${opts.resolution}&time=${opts.times.time}&timeEnd=${opts.times.timeEnd}&timeStart=${ + opts.times.timeStart + }${queries.length > 0 ? queries.join('') : ''}`, + }); + }; + + // 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 ( + + + + {displayName} + +

{description}

+ +
+ + + {options.queries.length > 0 && options.queries[0] !== '' ? ( + + ) : null} + +
+ ); +}; + +export default Page; diff --git a/plugins/prometheus/src/components/page/PageChart.tsx b/plugins/prometheus/src/components/page/PageChart.tsx new file mode 100644 index 000000000..de2e6c420 --- /dev/null +++ b/plugins/prometheus/src/components/page/PageChart.tsx @@ -0,0 +1,117 @@ +import { Card, CardBody, Flex, FlexItem, ToggleGroup, ToggleGroupItem } from '@patternfly/react-core'; +import React, { useState } from 'react'; +import { ResponsiveLineCanvas, Serie } from '@nivo/line'; +import { SquareIcon } from '@patternfly/react-icons'; + +import { convertMetrics, formatAxisBottom } from '../../utils/helpers'; +import { COLOR_SCALE } from '../../utils/colors'; +import { IMetric } from '../../utils/interfaces'; +import { IPluginTimes } from '@kobsio/plugin-core'; +import PageChartLegend from './PageChartLegend'; + +interface IPageChartProps { + queries: string[]; + times: IPluginTimes; + metrics: IMetric[]; +} + +// The PageChart component is used to render the chart for the given metrics. Above the chart we display two toggle +// groups so that the user can adjust the basic style of the chart. The user can switch between a line and area chart +// and between a stacked and unstacked visualization. At the bottom of the page we are including the PageChartLegend +// component to render the legend for the metrics. +const PageChart: React.FunctionComponent = ({ queries, times, metrics }: IPageChartProps) => { + // series is an array for the converted metrics, which can be used by nivo. We convert the metrics to a series, so + // that we have to do this onyl once and not everytime the selected metrics are changed. + const seriesData = convertMetrics(metrics); + + const [type, setType] = useState('line'); + const [stacked, setStacked] = useState(false); + const [selectedSeries, setSelectedSeries] = useState(seriesData.series); + + // 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 = (color: string, metric: Serie): void => { + if (selectedSeries.length === 1 && selectedSeries[0].label === metric.label) { + setSelectedSeries(seriesData.series); + } else { + setSelectedSeries([{ ...metric, color: color }]); + } + }; + + return ( + + + + + + setType('line')} /> + setType('area')} /> + + + + + setStacked(false)} /> + setStacked(true)} /> + + + +

 

+
+ -.2f', + legend: 'Value', + legendOffset: -40, + legendPosition: 'middle', + }} + colors={COLOR_SCALE} + curve="monotoneX" + data={selectedSeries} + enableArea={type === 'area'} + enableGridX={false} + enableGridY={true} + enablePoints={false} + xFormat="time:%Y-%m-%d %H:%M:%S" + lineWidth={1} + margin={{ bottom: 25, left: 50, right: 0, top: 0 }} + theme={{ + background: '#ffffff', + fontFamily: 'RedHatDisplay, Overpass, overpass, helvetica, arial, sans-serif', + fontSize: 10, + textColor: '#000000', + }} + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + tooltip={(tooltip) => ( +
+
+ {tooltip.point.data.xFormatted} +
+
+ {seriesData.labels[tooltip.point.id.split('.')[0]]}:{' '} + {tooltip.point.data.yFormatted} +
+
+ )} + xScale={{ type: 'time' }} + yScale={{ stacked: stacked, type: 'linear' }} + yFormat=" >-.2f" + /> +
+

 

+ +
+
+ ); +}; + +export default PageChart; diff --git a/plugins/prometheus/src/components/page/PageChartLegend.tsx b/plugins/prometheus/src/components/page/PageChartLegend.tsx new file mode 100644 index 000000000..657a07b5c --- /dev/null +++ b/plugins/prometheus/src/components/page/PageChartLegend.tsx @@ -0,0 +1,68 @@ +import { Button, ButtonVariant } from '@patternfly/react-core'; +import { EyeSlashIcon, SquareIcon } from '@patternfly/react-icons'; +import { TableComposable, TableVariant, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; +import React from 'react'; +import { Serie } from '@nivo/line'; + +import { getColor } from '../../utils/colors'; + +interface IPageChartLegendProps { + queries: string[]; + series: Serie[]; + selected: Serie[]; + select: (color: string, serie: Serie) => void; +} + +// The PageChartLegend component renders the legend for the returned metrics. The legend is displayed in a table format +// and also includes the min, max and avg value for a metric. When the user clicks on a row he can add / remove a metric +// from the selected metrics. We then only display the selected metrics in the chart. +const PageChartLegend: React.FunctionComponent = ({ + queries, + series, + selected, + select, +}: IPageChartLegendProps) => { + return ( + + + + Name + Min + Max + Avg + Current + + + + {series.map((metric, index) => ( + + + + + {metric.min} + {metric.max} + {metric.avg} + {metric.data[metric.data.length - 1].y} + + ))} + + + ); +}; + +export default PageChartLegend; diff --git a/plugins/prometheus/src/components/page/PageChartWrapper.tsx b/plugins/prometheus/src/components/page/PageChartWrapper.tsx new file mode 100644 index 000000000..3f238478b --- /dev/null +++ b/plugins/prometheus/src/components/page/PageChartWrapper.tsx @@ -0,0 +1,90 @@ +import { Alert, AlertActionLink, AlertVariant, Spinner } from '@patternfly/react-core'; +import { QueryObserverResult, useQuery } from 'react-query'; +import React from 'react'; +import { useHistory } from 'react-router-dom'; + +import { IMetric, IOptions } from '../../utils/interfaces'; +import PageChart from './PageChart'; + +interface IPageChartWrapperProps extends IOptions { + name: string; +} + +// The PageChartWrapper component is used as a wrapper for the PageChart component. The component is responsible for +// loading the metrics for the users entered queries and time range. For that we have to refetch the metrics everytime +// the queries, time range or resolution is changed. +const PageChartWrapper: React.FunctionComponent = ({ + name, + queries, + resolution, + times, +}: IPageChartWrapperProps) => { + const history = useHistory(); + + const { isError, isLoading, error, data, refetch } = useQuery( + ['prometheus/metrics', queries, resolution, times], + async () => { + try { + const response = await fetch(`/api/plugins/prometheus/metrics/${name}`, { + body: JSON.stringify({ + queries: queries.map((query) => { + return { label: '', query: query }; + }), + resolution: resolution, + timeEnd: times.timeEnd, + timeStart: times.timeStart, + }), + method: 'post', + }); + const json = await response.json(); + + if (response.status >= 200 && response.status < 300) { + return json; + } else { + if (json.error) { + throw new Error(json.error); + } else { + throw new Error('An unknown error occured'); + } + } + } catch (err) { + throw err; + } + }, + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError) { + return ( + + history.push('/')}>Home + > => refetch()}> + Retry + +
+ } + > +

{error?.message}

+ + ); + } + + if (!data) { + return null; + } + + return ; +}; + +export default PageChartWrapper; diff --git a/plugins/prometheus/src/components/page/PageToolbar.tsx b/plugins/prometheus/src/components/page/PageToolbar.tsx new file mode 100644 index 000000000..c5845de25 --- /dev/null +++ b/plugins/prometheus/src/components/page/PageToolbar.tsx @@ -0,0 +1,156 @@ +import { + Button, + ButtonVariant, + Flex, + FlexItem, + InputGroup, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, + ToolbarToggleGroup, +} from '@patternfly/react-core'; +import { FilterIcon, MinusIcon, PlusIcon, SearchIcon } from '@patternfly/react-icons'; +import React, { useState } from 'react'; + +import { IOptionsAdditionalFields, Options, TTime } from '@kobsio/plugin-core'; +import { IOptions } from '../../utils/interfaces'; +import { PageToolbarAutocomplete } from './PageToolbarAutocomplete'; + +// IPageToolbarProps is the interface for all properties, which can be passed to the PageToolbar component. This are all +// available Prometheus options and a function to write changes to these properties back to the parent component. +interface IPageToolbarProps extends IOptions { + name: string; + setOptions: (data: IOptions) => void; +} + +// PageToolbar 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 PageToolbar: React.FunctionComponent = ({ + name, + queries, + resolution, + times, + setOptions, +}: IPageToolbarProps) => { + const [data, setData] = useState({ + queries: queries, + resolution: resolution, + times: times, + }); + + const addQuery = (): void => { + const tmpQueries = [...data.queries]; + tmpQueries.push(''); + setData({ ...data, queries: tmpQueries }); + }; + + const removeQuery = (index: number): void => { + const tmpQueries = [...data.queries]; + tmpQueries.splice(index, 1); + setData({ ...data, queries: tmpQueries }); + }; + + // 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 = ( + refresh: boolean, + additionalFields: IOptionsAdditionalFields[] | undefined, + time: TTime, + timeEnd: number, + timeStart: number, + ): void => { + if (additionalFields && additionalFields.length === 1) { + const tmpData = { ...data }; + + if (refresh) { + setOptions({ + ...tmpData, + resolution: additionalFields[0].value, + times: { time: time, timeEnd: timeEnd, timeStart: timeStart }, + }); + } + + setData({ + ...tmpData, + resolution: additionalFields[0].value, + times: { time: time, 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"> + + + + {data.queries.map((query, index) => ( + + + changeQuery(index, value)} + onEnter={onEnter} + /> + {index === 0 ? ( + + ) : ( + + )} + + + ))} + + + + + + + + + + + + + ); +}; + +export default PageToolbar; diff --git a/plugins/prometheus/src/components/page/PageToolbarAutocomplete.tsx b/plugins/prometheus/src/components/page/PageToolbarAutocomplete.tsx new file mode 100644 index 000000000..d4816bd09 --- /dev/null +++ b/plugins/prometheus/src/components/page/PageToolbarAutocomplete.tsx @@ -0,0 +1,155 @@ +import { Button, ButtonVariant, TextArea } from '@patternfly/react-core'; +import React, { useRef, useState } from 'react'; +import { TimesIcon } from '@patternfly/react-icons'; +import { useQuery } from 'react-query'; + +import { useDebounce } from '@kobsio/plugin-core'; + +interface IPageToolbarAutocomplete { + name: string; + query: string; + setQuery: (q: string) => void; + onEnter: (e: React.KeyboardEvent | undefined) => void; +} + +// PageToolbarAutocomplete is used as input for the PromQL query. The component also fetches a list of suggestions for +// the provided input. +export const PageToolbarAutocomplete: React.FunctionComponent = ({ + name, + query, + setQuery, + onEnter, +}: IPageToolbarAutocomplete) => { + const debouncedQuery = useDebounce(query, 500); + const inputRef = useRef(null); + const [inputFocused, setInputFocused] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [hoveringMetricNamesList, setHovering] = useState(false); + + const { data } = useQuery(['prometheus/labelvalues', debouncedQuery], async () => { + try { + if (debouncedQuery === '') { + return undefined; + } + + const response = await fetch(`/api/plugins/prometheus/labels/${name}?searchTerm=${debouncedQuery}`, { + method: 'get', + }); + const json = await response.json(); + + if (response.status >= 200 && response.status < 300) { + return json; + } else { + if (json.error) { + throw new Error(json.error); + } else { + throw new Error('An unknown error occured'); + } + } + } catch (err) { + throw err; + } + }); + + // onFocus is used to set the inputFocused variable to true, when the TextArea is focused. + const onFocus = (): void => { + setInputFocused(true); + }; + + // onBlur is used to set the inputFocused variable to false, when the TextArea looses the focus. + const onBlur = (): void => { + setInputFocused(false); + }; + + // onKeyDown is used to navigate to the suggestion list via the arrow up and arrow down key. When a item is selected + // and the user presses the enter key, the selected item will be used for the query. When no item is selected and the + // user presses the enter key, the search is executed. + const onKeyDown = (e: React.KeyboardEvent | undefined): void => { + if (e?.key === 'ArrowUp') { + if (data && selectedIndex === 0) { + setSelectedIndex(data.length - 1); + } else { + setSelectedIndex(selectedIndex - 1); + } + } else if (data && e?.key === 'ArrowDown') { + if (selectedIndex + 1 === data.length) { + setSelectedIndex(0); + } else { + setSelectedIndex(selectedIndex + 1); + } + } else if (data && e?.key === 'Enter' && selectedIndex > -1) { + e.preventDefault(); + setQuery(data[selectedIndex]); + setSelectedIndex(-1); + } else { + onEnter(e); + } + }; + + // onMouseDown is used, when a user selects an item from the suggestion via the mouse. When the item is selected, we + // switch the focus back to the TextArea component. + const onMouseDown = (result: string): void => { + setQuery(result); + setHovering(false); + + setTimeout(() => { + inputRef.current?.focus(); + }, 50); + }; + + return ( + +
+
+ +