From 8c2bc2f57a4e01121dbe0377fbaa7805311afc8e Mon Sep 17 00:00:00 2001 From: Boyko Date: Mon, 3 Feb 2020 16:14:25 +0200 Subject: [PATCH] Unify react fetcher components (#6629) * set useFetch loading flag to be true initially Signed-off-by: blalov * make extended props optional Signed-off-by: blalov * add status indicator to targets page Signed-off-by: blalov * add status indicator to tsdb status page Signed-off-by: blalov * spread response in Alerts Signed-off-by: blalov * disable eslint func retun type rule Signed-off-by: blalov * add status indicator to Service Discovery page Signed-off-by: blalov * refactor PanelList Signed-off-by: blalov * test fix Signed-off-by: blalov * use local storage hook in PanelList Signed-off-by: blalov * use 'useFetch' for fetching metrics Signed-off-by: blalov * left-overs Signed-off-by: blalov * remove targets page custom error message Signed-off-by: Boyko Lalov * adding components displayName Signed-off-by: Boyko Lalov * display more user friendly error messages Signed-off-by: Boyko Lalov * update status page snapshot Signed-off-by: Boyko Lalov * pr review changes Signed-off-by: Boyko Lalov * fix broken tests Signed-off-by: Boyko Lalov * fix typos Signed-off-by: Boyko Lalov --- web/ui/react-app/.eslintrc.json | 1 + web/ui/react-app/src/App.test.tsx | 4 +- web/ui/react-app/src/App.tsx | 4 +- .../src/components/withStatusIndicator.tsx | 4 +- web/ui/react-app/src/hooks/useFetch.ts | 8 +- .../react-app/src/hooks/useLocalStorage.tsx | 4 +- web/ui/react-app/src/pages/alerts/Alerts.tsx | 9 +- .../src/pages/graph/PanelList.test.tsx | 23 +- .../react-app/src/pages/graph/PanelList.tsx | 311 ++++++-------- web/ui/react-app/src/pages/index.ts | 4 +- .../src/pages/serviceDiscovery/Services.tsx | 175 ++++---- .../src/pages/status/Status.test.tsx | 6 +- web/ui/react-app/src/pages/status/Status.tsx | 99 +++-- .../status/__snapshots__/Status.test.tsx.snap | 394 +----------------- .../src/pages/targets/ScrapePoolList.test.tsx | 24 +- .../src/pages/targets/ScrapePoolList.tsx | 76 ++-- .../src/pages/tsdbStatus/TSDBStatus.test.tsx | 38 +- .../src/pages/tsdbStatus/TSDBStatus.tsx | 120 +++--- web/ui/react-app/src/utils/index.ts | 9 + 19 files changed, 406 insertions(+), 907 deletions(-) diff --git a/web/ui/react-app/.eslintrc.json b/web/ui/react-app/.eslintrc.json index 395ca48af20..1f549df7375 100644 --- a/web/ui/react-app/.eslintrc.json +++ b/web/ui/react-app/.eslintrc.json @@ -7,6 +7,7 @@ ], "rules": { "@typescript-eslint/camelcase": "warn", + "@typescript-eslint/explicit-function-return-type": ["off"], "eol-last": [ "error", "always" diff --git a/web/ui/react-app/src/App.test.tsx b/web/ui/react-app/src/App.test.tsx index 813dbb0b621..705e0bc6b8a 100755 --- a/web/ui/react-app/src/App.test.tsx +++ b/web/ui/react-app/src/App.test.tsx @@ -4,7 +4,7 @@ import App from './App'; import Navigation from './Navbar'; import { Container } from 'reactstrap'; import { Router } from '@reach/router'; -import { Alerts, Config, Flags, Rules, Services, Status, Targets, TSDBStatus, PanelList } from './pages'; +import { Alerts, Config, Flags, Rules, ServiceDiscovery, Status, Targets, TSDBStatus, PanelList } from './pages'; describe('App', () => { const app = shallow(); @@ -13,7 +13,7 @@ describe('App', () => { expect(app.find(Navigation)).toHaveLength(1); }); it('routes', () => { - [Alerts, Config, Flags, Rules, Services, Status, Targets, TSDBStatus, PanelList].forEach(component => { + [Alerts, Config, Flags, Rules, ServiceDiscovery, Status, Targets, TSDBStatus, PanelList].forEach(component => { const c = app.find(component); expect(c).toHaveLength(1); expect(c.prop('pathPrefix')).toBe('/path/prefix'); diff --git a/web/ui/react-app/src/App.tsx b/web/ui/react-app/src/App.tsx index faee2e19202..68b3ce48581 100755 --- a/web/ui/react-app/src/App.tsx +++ b/web/ui/react-app/src/App.tsx @@ -4,7 +4,7 @@ import { Container } from 'reactstrap'; import './App.css'; import { Router, Redirect } from '@reach/router'; -import { Alerts, Config, Flags, Rules, Services, Status, Targets, TSDBStatus, PanelList } from './pages'; +import { Alerts, Config, Flags, Rules, ServiceDiscovery, Status, Targets, TSDBStatus, PanelList } from './pages'; import PathPrefixProps from './types/PathPrefixProps'; const App: FC = ({ pathPrefix }) => { @@ -24,7 +24,7 @@ const App: FC = ({ pathPrefix }) => { - + diff --git a/web/ui/react-app/src/components/withStatusIndicator.tsx b/web/ui/react-app/src/components/withStatusIndicator.tsx index 356a671dd36..b2d9f18b3d8 100644 --- a/web/ui/react-app/src/components/withStatusIndicator.tsx +++ b/web/ui/react-app/src/components/withStatusIndicator.tsx @@ -7,12 +7,14 @@ interface StatusIndicatorProps { error?: Error; isLoading?: boolean; customErrorMsg?: JSX.Element; + componentTitle?: string; } export const withStatusIndicator = (Component: ComponentType): FC => ({ error, isLoading, customErrorMsg, + componentTitle, ...rest }) => { if (error) { @@ -22,7 +24,7 @@ export const withStatusIndicator = (Component: ComponentType): customErrorMsg ) : ( <> - Error: Error fetching {Component.displayName}: {error.message} + Error: Error fetching {componentTitle || Component.displayName}: {error.message} )} diff --git a/web/ui/react-app/src/hooks/useFetch.ts b/web/ui/react-app/src/hooks/useFetch.ts index 2f2fc92a726..5f18b369a9a 100644 --- a/web/ui/react-app/src/hooks/useFetch.ts +++ b/web/ui/react-app/src/hooks/useFetch.ts @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; -export type APIResponse = { status: string; data?: T }; +export type APIResponse = { status: string; data: T }; export interface FetchState { response: APIResponse; @@ -9,15 +9,15 @@ export interface FetchState { } export const useFetch = (url: string, options?: RequestInit): FetchState => { - const [response, setResponse] = useState>({ status: 'start fetching' }); + const [response, setResponse] = useState>({ status: 'start fetching' } as any); const [error, setError] = useState(); - const [isLoading, setIsLoading] = useState(false); + const [isLoading, setIsLoading] = useState(true); useEffect(() => { const fetchData = async () => { setIsLoading(true); try { - const res = await fetch(url, { cache: 'no-cache', credentials: 'same-origin', ...options }); + const res = await fetch(url, { cache: 'no-store', credentials: 'same-origin', ...options }); if (!res.ok) { throw new Error(res.statusText); } diff --git a/web/ui/react-app/src/hooks/useLocalStorage.tsx b/web/ui/react-app/src/hooks/useLocalStorage.tsx index 91b85a32794..75842e769dc 100644 --- a/web/ui/react-app/src/hooks/useLocalStorage.tsx +++ b/web/ui/react-app/src/hooks/useLocalStorage.tsx @@ -1,8 +1,8 @@ import { Dispatch, SetStateAction, useEffect, useState } from 'react'; export function useLocalStorage(localStorageKey: string, initialState: S): [S, Dispatch>] { - const localStorageState = JSON.parse(localStorage.getItem(localStorageKey) as string); - const [value, setValue] = useState(localStorageState || initialState); + const localStorageState = JSON.parse(localStorage.getItem(localStorageKey) || JSON.stringify(initialState)); + const [value, setValue] = useState(localStorageState); useEffect(() => { const serializedState = JSON.stringify(value); diff --git a/web/ui/react-app/src/pages/alerts/Alerts.tsx b/web/ui/react-app/src/pages/alerts/Alerts.tsx index 083309f99ec..45e4ff1ffed 100644 --- a/web/ui/react-app/src/pages/alerts/Alerts.tsx +++ b/web/ui/react-app/src/pages/alerts/Alerts.tsx @@ -20,14 +20,7 @@ const Alerts: FC = ({ pathPrefix = '' }) response.data.groups.forEach(el => el.rules.forEach(r => ruleStatsCount[r.state]++)); } - return ( - - ); + return ; }; export default Alerts; diff --git a/web/ui/react-app/src/pages/graph/PanelList.test.tsx b/web/ui/react-app/src/pages/graph/PanelList.test.tsx index 528b57a1d17..c446ac9a397 100755 --- a/web/ui/react-app/src/pages/graph/PanelList.test.tsx +++ b/web/ui/react-app/src/pages/graph/PanelList.test.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; -import { mount, shallow } from 'enzyme'; -import PanelList from './PanelList'; +import { shallow } from 'enzyme'; +import PanelList, { PanelListContent } from './PanelList'; import Checkbox from '../../components/Checkbox'; -import { Alert, Button } from 'reactstrap'; +import { Button } from 'reactstrap'; import Panel from './Panel'; describe('PanelList', () => { @@ -14,31 +14,20 @@ describe('PanelList', () => { const panelList = shallow(); const checkbox = panelList.find(Checkbox).at(idx); expect(checkbox.prop('id')).toEqual(cb.id); - expect(checkbox.prop('wrapperStyles')).toEqual({ - margin: '0 0 0 15px', - alignSelf: 'center', - }); expect(checkbox.prop('defaultChecked')).toBe(false); expect(checkbox.children().text()).toBe(cb.label); }); }); - it('renders an alert when no data is queried yet', () => { - const panelList = mount(); - const alert = panelList.find(Alert); - expect(alert.prop('color')).toEqual('light'); - expect(alert.children().text()).toEqual('No data queried yet'); - }); - it('renders panels', () => { - const panelList = shallow(); + const panelList = shallow(); const panels = panelList.find(Panel); expect(panels.length).toBeGreaterThan(0); }); it('renders a button to add a panel', () => { - const panelList = shallow(); - const btn = panelList.find(Button).filterWhere(btn => btn.prop('className') === 'add-panel-btn'); + const panelList = shallow(); + const btn = panelList.find(Button); expect(btn.prop('color')).toEqual('primary'); expect(btn.children().text()).toEqual('Add Panel'); }); diff --git a/web/ui/react-app/src/pages/graph/PanelList.tsx b/web/ui/react-app/src/pages/graph/PanelList.tsx index d7d51f6354d..15f75dee4ed 100644 --- a/web/ui/react-app/src/pages/graph/PanelList.tsx +++ b/web/ui/react-app/src/pages/graph/PanelList.tsx @@ -1,219 +1,170 @@ -import React, { Component, ChangeEvent } from 'react'; +import React, { FC, useState, useEffect } from 'react'; import { RouteComponentProps } from '@reach/router'; - -import { Alert, Button, Col, Row } from 'reactstrap'; +import { Alert, Button } from 'reactstrap'; import Panel, { PanelOptions, PanelDefaultOptions } from './Panel'; import Checkbox from '../../components/Checkbox'; import PathPrefixProps from '../../types/PathPrefixProps'; -import { generateID, decodePanelOptionsFromQueryString, encodePanelOptionsToQueryString } from '../../utils'; +import { generateID, decodePanelOptionsFromQueryString, encodePanelOptionsToQueryString, callAll } from '../../utils'; +import { useFetch } from '../../hooks/useFetch'; +import { useLocalStorage } from '../../hooks/useLocalStorage'; -export type MetricGroup = { title: string; items: string[] }; export type PanelMeta = { key: string; options: PanelOptions; id: string }; -interface PanelListState { +export const updateURL = (nextPanels: PanelMeta[]) => { + const query = encodePanelOptionsToQueryString(nextPanels); + window.history.pushState({}, '', query); +}; + +interface PanelListProps extends PathPrefixProps, RouteComponentProps { panels: PanelMeta[]; - pastQueries: string[]; - metricNames: string[]; - fetchMetricsError: string | null; - timeDriftError: string | null; + metrics: string[]; useLocalTime: boolean; + queryHistoryEnabled: boolean; } -class PanelList extends Component { - constructor(props: RouteComponentProps & PathPrefixProps) { - super(props); - - this.state = { - panels: decodePanelOptionsFromQueryString(window.location.search), - pastQueries: [], - metricNames: [], - fetchMetricsError: null, - timeDriftError: null, - useLocalTime: this.useLocalTime(), - }; - } - - componentDidMount() { - !this.state.panels.length && this.addPanel(); - fetch(`${this.props.pathPrefix}/api/v1/label/__name__/values`, { cache: 'no-store', credentials: 'same-origin' }) - .then(resp => { - if (resp.ok) { - return resp.json(); - } else { - throw new Error('Unexpected response status when fetching metric names: ' + resp.statusText); // TODO extract error - } - }) - .then(json => { - this.setState({ metricNames: json.data }); - }) - .catch(error => this.setState({ fetchMetricsError: error.message })); - - const browserTime = new Date().getTime() / 1000; - fetch(`${this.props.pathPrefix}/api/v1/query?query=time()`, { cache: 'no-store', credentials: 'same-origin' }) - .then(resp => { - if (resp.ok) { - return resp.json(); - } else { - throw new Error('Unexpected response status when fetching metric names: ' + resp.statusText); // TODO extract error - } - }) - .then(json => { - const serverTime = json.data.result[0]; - const delta = Math.abs(browserTime - serverTime); - - if (delta >= 30) { - throw new Error( - 'Detected ' + - delta + - ' seconds time difference between your browser and the server. Prometheus relies on accurate time and time drift might cause unexpected query results.' - ); - } - }) - .catch(error => this.setState({ timeDriftError: error.message })); - +export const PanelListContent: FC = ({ + metrics = [], + useLocalTime, + pathPrefix, + queryHistoryEnabled, + ...rest +}) => { + const [panels, setPanels] = useState(rest.panels); + const [historyItems, setLocalStorageHistoryItems] = useLocalStorage('history', []); + + useEffect(() => { + !panels.length && addPanel(); window.onpopstate = () => { const panels = decodePanelOptionsFromQueryString(window.location.search); if (panels.length > 0) { - this.setState({ panels }); + setPanels(panels); } }; + // We want useEffect to act only as componentDidMount, but react still complains about the empty dependencies list. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - this.updatePastQueries(); - } - - isHistoryEnabled = () => JSON.parse(localStorage.getItem('enable-query-history') || 'false') as boolean; - - getHistoryItems = () => JSON.parse(localStorage.getItem('history') || '[]') as string[]; - - toggleQueryHistory = (e: ChangeEvent) => { - localStorage.setItem('enable-query-history', `${e.target.checked}`); - this.updatePastQueries(); - }; - - updatePastQueries = () => { - this.setState({ - pastQueries: this.isHistoryEnabled() ? this.getHistoryItems() : [], - }); - }; - - useLocalTime = () => JSON.parse(localStorage.getItem('use-local-time') || 'false') as boolean; - - toggleUseLocalTime = (e: ChangeEvent) => { - localStorage.setItem('use-local-time', `${e.target.checked}`); - this.setState({ useLocalTime: e.target.checked }); - }; - - handleExecuteQuery = (query: string) => { - const isSimpleMetric = this.state.metricNames.indexOf(query) !== -1; + const handleExecuteQuery = (query: string) => { + const isSimpleMetric = metrics.indexOf(query) !== -1; if (isSimpleMetric || !query.length) { return; } - const historyItems = this.getHistoryItems(); const extendedItems = historyItems.reduce( (acc, metric) => { return metric === query ? acc : [...acc, metric]; // Prevent adding query twice. }, [query] ); - localStorage.setItem('history', JSON.stringify(extendedItems.slice(0, 50))); - this.updatePastQueries(); + setLocalStorageHistoryItems(extendedItems.slice(0, 50)); }; - updateURL() { - const query = encodePanelOptionsToQueryString(this.state.panels); - window.history.pushState({}, '', query); - } - - handleOptionsChanged = (id: string, options: PanelOptions) => { - const updatedPanels = this.state.panels.map(p => (id === p.id ? { ...p, options } : p)); - this.setState({ panels: updatedPanels }, this.updateURL); - }; - - addPanel = () => { - const { panels } = this.state; - const nextPanels = [ + const addPanel = () => { + callAll(setPanels, updateURL)([ ...panels, { id: generateID(), key: `${panels.length}`, options: PanelDefaultOptions, }, - ]; - this.setState({ panels: nextPanels }, this.updateURL); - }; - - removePanel = (id: string) => { - this.setState( - { - panels: this.state.panels.reduce((acc, panel) => { - return panel.id !== id ? [...acc, { ...panel, key: `${acc.length}` }] : acc; - }, []), - }, - this.updateURL - ); + ]); }; - render() { - const { metricNames, pastQueries, timeDriftError, fetchMetricsError, panels } = this.state; - const { pathPrefix } = this.props; - return ( - <> - - - Enable query history - - - Use local time - - - - - {timeDriftError && ( - - Warning: Error fetching server time: {timeDriftError} - - )} - - - - - {fetchMetricsError && ( - - Warning: Error fetching metrics list: {fetchMetricsError} - - )} - - - {panels.map(({ id, options }) => ( - this.handleOptionsChanged(id, opts)} - useLocalTime={this.state.useLocalTime} - removePanel={() => this.removePanel(id)} - metricNames={metricNames} - pastQueries={pastQueries} - pathPrefix={pathPrefix} - /> - ))} - - - ); - } -} + return ( + <> + {panels.map(({ id, options }) => ( + + callAll(setPanels, updateURL)(panels.map(p => (id === p.id ? { ...p, options: opts } : p))) + } + removePanel={() => + callAll(setPanels, updateURL)( + panels.reduce( + (acc, panel) => (panel.id !== id ? [...acc, { ...panel, key: `${acc.length}` }] : acc), + [] + ) + ) + } + useLocalTime={useLocalTime} + metricNames={metrics} + pastQueries={queryHistoryEnabled ? historyItems : []} + pathPrefix={pathPrefix} + /> + ))} + + + ); +}; + +const PanelList: FC = ({ pathPrefix = '' }) => { + const [delta, setDelta] = useState(0); + const [useLocalTime, setUseLocalTime] = useLocalStorage('use-local-time', false); + const [enableQueryHistory, setEnableQueryHistory] = useLocalStorage('enable-query-history', false); + + const { response: metricsRes, error: metricsErr } = useFetch(`${pathPrefix}/api/v1/label/__name__/values`); + + const browserTime = new Date().getTime() / 1000; + const { response: timeRes, error: timeErr } = useFetch<{ result: number[] }>(`${pathPrefix}/api/v1/query?query=time()`); + + useEffect(() => { + if (timeRes.data) { + const serverTime = timeRes.data.result[0]; + setDelta(Math.abs(browserTime - serverTime)); + } + /** + * React wants to include browserTime to useEffect dependencie list which will cause a delta change on every re-render + * Basically it's not recommended to disable this rule, but this is the only way to take control over the useEffect + * dependencies and to not include the browserTime variable. + **/ + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [timeRes.data]); + + return ( + <> + setEnableQueryHistory(target.checked)} + defaultChecked={enableQueryHistory} + > + Enable query history + + setUseLocalTime(target.checked)} + defaultChecked={useLocalTime} + > + Use local time + + {(delta > 30 || timeErr) && ( + + Warning: + {timeErr && `Unexpected response status when fetching server time: ${timeErr.message}`} + {delta >= 30 && + `Error fetching server time: Detected ${delta} seconds time difference between your browser and the server. Prometheus relies on accurate time and time drift might cause unexpected query results.`} + + )} + {metricsErr && ( + + Warning: + Error fetching metrics list: Unexpected response status when fetching metric names: {metricsErr.message} + + )} + + + ); +}; export default PanelList; diff --git a/web/ui/react-app/src/pages/index.ts b/web/ui/react-app/src/pages/index.ts index 66fd65861b1..d6f07d798d0 100644 --- a/web/ui/react-app/src/pages/index.ts +++ b/web/ui/react-app/src/pages/index.ts @@ -2,10 +2,10 @@ import Alerts from './alerts/Alerts'; import Config from './config/Config'; import Flags from './flags/Flags'; import Rules from './rules/Rules'; -import Services from './serviceDiscovery/Services'; +import ServiceDiscovery from './serviceDiscovery/Services'; import Status from './status/Status'; import Targets from './targets/Targets'; import PanelList from './graph/PanelList'; import TSDBStatus from './tsdbStatus/TSDBStatus'; -export { Alerts, Config, Flags, Rules, Services, Status, Targets, TSDBStatus, PanelList }; +export { Alerts, Config, Flags, Rules, ServiceDiscovery, Status, Targets, TSDBStatus, PanelList }; diff --git a/web/ui/react-app/src/pages/serviceDiscovery/Services.tsx b/web/ui/react-app/src/pages/serviceDiscovery/Services.tsx index 86905217560..87aabb17ad4 100644 --- a/web/ui/react-app/src/pages/serviceDiscovery/Services.tsx +++ b/web/ui/react-app/src/pages/serviceDiscovery/Services.tsx @@ -1,14 +1,13 @@ import React, { FC } from 'react'; import { RouteComponentProps } from '@reach/router'; import PathPrefixProps from '../../types/PathPrefixProps'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faSpinner } from '@fortawesome/free-solid-svg-icons'; -import { Alert } from 'reactstrap'; import { useFetch } from '../../hooks/useFetch'; import { LabelsTable } from './LabelsTable'; import { Target, Labels, DroppedTarget } from '../targets/target'; -// TODO: Deduplicate with https://github.com/prometheus/prometheus/blob/213a8fe89a7308e73f22888a963cbf9375217cd6/web/ui/react-app/src/pages/targets/ScrapePoolList.tsx#L11-L14 +import { withStatusIndicator } from '../../components/withStatusIndicator'; +import { mapObjEntries } from '../../utils'; + interface ServiceMap { activeTargets: Target[]; droppedTargets: DroppedTarget[]; @@ -20,100 +19,102 @@ export interface TargetLabels { isDropped: boolean; } -const Services: FC = ({ pathPrefix }) => { - const { response, error } = useFetch(`${pathPrefix}/api/v1/targets`); - - const processSummary = (response: ServiceMap) => { - const targets: any = {}; +export const processSummary = (activeTargets: Target[], droppedTargets: DroppedTarget[]) => { + const targets: Record = {}; - // Get targets of each type along with the total and active end points - for (const target of response.activeTargets) { - const { scrapePool: name } = target; - if (!targets[name]) { - targets[name] = { - total: 0, - active: 0, - }; - } - targets[name].total++; - targets[name].active++; + // Get targets of each type along with the total and active end points + for (const target of activeTargets) { + const { scrapePool: name } = target; + if (!targets[name]) { + targets[name] = { + total: 0, + active: 0, + }; } - for (const target of response.droppedTargets) { - const { job: name } = target.discoveredLabels; - if (!targets[name]) { - targets[name] = { - total: 0, - active: 0, - }; - } - targets[name].total++; + targets[name].total++; + targets[name].active++; + } + for (const target of droppedTargets) { + const { job: name } = target.discoveredLabels; + if (!targets[name]) { + targets[name] = { + total: 0, + active: 0, + }; } + targets[name].total++; + } - return targets; - }; + return targets; +}; - const processTargets = (response: Target[], dropped: DroppedTarget[]) => { - const labels: Record = {}; +export const processTargets = (activeTargets: Target[], droppedTargets: DroppedTarget[]) => { + const labels: Record = {}; - for (const target of response) { - const name = target.scrapePool; - if (!labels[name]) { - labels[name] = []; - } - labels[name].push({ - discoveredLabels: target.discoveredLabels, - labels: target.labels, - isDropped: false, - }); + for (const target of activeTargets) { + const name = target.scrapePool; + if (!labels[name]) { + labels[name] = []; } + labels[name].push({ + discoveredLabels: target.discoveredLabels, + labels: target.labels, + isDropped: false, + }); + } - for (const target of dropped) { - const { job: name } = target.discoveredLabels; - if (!labels[name]) { - labels[name] = []; - } - labels[name].push({ - discoveredLabels: target.discoveredLabels, - isDropped: true, - labels: {}, - }); + for (const target of droppedTargets) { + const { job: name } = target.discoveredLabels; + if (!labels[name]) { + labels[name] = []; } + labels[name].push({ + discoveredLabels: target.discoveredLabels, + isDropped: true, + labels: {}, + }); + } - return labels; - }; + return labels; +}; - if (error) { - return ( - - Error: Error fetching Service-Discovery: {error.message} - - ); - } else if (response.data) { - const targets = processSummary(response.data); - const labels = processTargets(response.data.activeTargets, response.data.droppedTargets); +export const ServiceDiscoveryContent: FC = ({ activeTargets, droppedTargets }) => { + const targets = processSummary(activeTargets, droppedTargets); + const labels = processTargets(activeTargets, droppedTargets); - return ( - <> -

Service Discovery

- -
- {Object.keys(labels).map((val: any, i) => { - const value = labels[val]; - return ; - })} - - ); - } - return ; + return ( + <> +

Service Discovery

+ +
+ {mapObjEntries(labels, ([k, v]) => { + return ; + })} + + ); +}; +ServiceDiscoveryContent.displayName = 'ServiceDiscoveryContent'; + +const ServicesWithStatusIndicator = withStatusIndicator(ServiceDiscoveryContent); + +const ServiceDiscovery: FC = ({ pathPrefix }) => { + const { response, error, isLoading } = useFetch(`${pathPrefix}/api/v1/targets`); + return ( + + ); }; -export default Services; +export default ServiceDiscovery; diff --git a/web/ui/react-app/src/pages/status/Status.test.tsx b/web/ui/react-app/src/pages/status/Status.test.tsx index 537ff14b10e..72c442af284 100644 --- a/web/ui/react-app/src/pages/status/Status.test.tsx +++ b/web/ui/react-app/src/pages/status/Status.test.tsx @@ -4,10 +4,6 @@ import toJson from 'enzyme-to-json'; import { StatusContent } from './Status'; describe('Status', () => { - it('should not fail with undefined data', () => { - const wrapper = shallow(); - expect(wrapper).toHaveLength(1); - }); describe('Snapshot testing', () => { const response: any = [ { @@ -45,7 +41,7 @@ describe('Status', () => { }, ]; it('should match table snapshot', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(toJson(wrapper)).toMatchSnapshot(); jest.restoreAllMocks(); }); diff --git a/web/ui/react-app/src/pages/status/Status.tsx b/web/ui/react-app/src/pages/status/Status.tsx index 6673c9e59f4..b2c06cd4dc4 100644 --- a/web/ui/react-app/src/pages/status/Status.tsx +++ b/web/ui/react-app/src/pages/status/Status.tsx @@ -5,19 +5,15 @@ import { withStatusIndicator } from '../../components/withStatusIndicator'; import { useFetch } from '../../hooks/useFetch'; import PathPrefixProps from '../../types/PathPrefixProps'; -const sectionTitles = ['Runtime Information', 'Build Information', 'Alertmanagers']; - -interface StatusConfig { - [k: string]: { title?: string; customizeValue?: (v: any, key: string) => any; customRow?: boolean; skip?: boolean }; -} - -type StatusPageState = { [k: string]: string }; - interface StatusPageProps { - data?: StatusPageState[]; + data: Record; + title: string; } -export const statusConfig: StatusConfig = { +export const statusConfig: Record< + string, + { title?: string; customizeValue?: (v: any, key: string) => any; customRow?: boolean; skip?: boolean } +> = { startTime: { title: 'Start time', customizeValue: (v: string) => new Date(v).toUTCString() }, CWD: { title: 'Working directory' }, reloadConfigSuccess: { @@ -56,37 +52,31 @@ export const statusConfig: StatusConfig = { droppedAlertmanagers: { skip: true }, }; -export const StatusContent: FC = ({ data = [] }) => { +export const StatusContent: FC = ({ data, title }) => { return ( <> - {data.map((statuses, i) => { - return ( - -

{sectionTitles[i]}

- - - {Object.entries(statuses).map(([k, v]) => { - const { title = k, customizeValue = (val: any) => val, customRow, skip } = statusConfig[k] || {}; - if (skip) { - return null; - } - if (customRow) { - return customizeValue(v, k); - } - return ( - - - - - ); - })} - -
- {title} - {customizeValue(v, title)}
-
- ); - })} +

{title}

+ + + {Object.entries(data).map(([k, v]) => { + const { title = k, customizeValue = (val: any) => val, customRow, skip } = statusConfig[k] || {}; + if (skip) { + return null; + } + if (customRow) { + return customizeValue(v, k); + } + return ( + + + + + ); + })} + +
+ {title} + {customizeValue(v, title)}
); }; @@ -96,21 +86,26 @@ StatusContent.displayName = 'Status'; const Status: FC = ({ pathPrefix = '' }) => { const path = `${pathPrefix}/api/v1`; - const status = useFetch(`${path}/status/runtimeinfo`); - const runtime = useFetch(`${path}/status/buildinfo`); - const build = useFetch(`${path}/alertmanagers`); - - let data; - if (status.response.data && runtime.response.data && build.response.data) { - data = [status.response.data, runtime.response.data, build.response.data]; - } return ( - + <> + {[ + { fetchResult: useFetch>(`${path}/status/runtimeinfo`), title: 'Runtime Information' }, + { fetchResult: useFetch>(`${path}/status/buildinfo`), title: 'Build Information' }, + { fetchResult: useFetch>(`${path}/alertmanagers`), title: 'Alertmanagers' }, + ].map(({ fetchResult, title }) => { + const { response, isLoading, error } = fetchResult; + return ( + + ); + })} + ); }; diff --git a/web/ui/react-app/src/pages/status/__snapshots__/Status.test.tsx.snap b/web/ui/react-app/src/pages/status/__snapshots__/Status.test.tsx.snap index fe93cf93c2c..fb6eef17d80 100644 --- a/web/ui/react-app/src/pages/status/__snapshots__/Status.test.tsx.snap +++ b/web/ui/react-app/src/pages/status/__snapshots__/Status.test.tsx.snap @@ -3,7 +3,7 @@ exports[`Status Snapshot testing should match table snapshot 1`] = `

- Runtime Information + Foo

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Start time - - Wed, 30 Oct 2019 20:03:23 GMT -
- Working directory - - /home/boyskila/Desktop/prometheus -
- Configuration reload - - Successful -
- Last successful configuration reload - - 2019-10-30T22:03:23+02:00 -
- Head chunks - - 1383 -
- Head time series - - 461 -
- WAL corruptions - 0 -
- Goroutines - - 37 -
- GOMAXPROCS - 4 +
- GOGC - -
- GODEBUG - -
- Storage retention + 1 - 15d +
-

- Build Information -

- - - - - - - - - - - - - - - - - - - -
- version - -
- revision + 2 -
- branch - -
- buildUser - -
- buildDate - -
- goVersion - - go1.13.3 -
-

- Alertmanagers -

- - - - - - - - - - - - - - - - - - - - - - diff --git a/web/ui/react-app/src/pages/targets/ScrapePoolList.test.tsx b/web/ui/react-app/src/pages/targets/ScrapePoolList.test.tsx index ad3d6c9fcff..be57f4c8a13 100644 --- a/web/ui/react-app/src/pages/targets/ScrapePoolList.test.tsx +++ b/web/ui/react-app/src/pages/targets/ScrapePoolList.test.tsx @@ -1,13 +1,11 @@ import * as React from 'react'; -import { mount, shallow, ReactWrapper } from 'enzyme'; +import { mount, ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { Alert } from 'reactstrap'; import { sampleApiResponse } from './__testdata__/testdata'; import ScrapePoolList from './ScrapePoolList'; import ScrapePoolPanel from './ScrapePoolPanel'; import { Target } from './target'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faSpinner } from '@fortawesome/free-solid-svg-icons'; import { FetchMock } from 'jest-fetch-mock/types'; describe('ScrapePoolList', () => { @@ -20,20 +18,6 @@ describe('ScrapePoolList', () => { fetchMock.resetMocks(); }); - describe('before data is returned', () => { - const scrapePoolList = shallow(); - const spinner = scrapePoolList.find(FontAwesomeIcon); - - it('renders a spinner', () => { - expect(spinner.prop('icon')).toEqual(faSpinner); - expect(spinner.prop('spin')).toBe(true); - }); - - it('renders exactly one spinner', () => { - expect(spinner).toHaveLength(1); - }); - }); - describe('when data is returned', () => { let scrapePoolList: ReactWrapper; let mock: FetchMock; @@ -55,7 +39,7 @@ describe('ScrapePoolList', () => { scrapePoolList = mount(); }); scrapePoolList.update(); - expect(mock).toHaveBeenCalledWith('../api/v1/targets?state=active', { cache: 'no-cache', credentials: 'same-origin' }); + expect(mock).toHaveBeenCalledWith('../api/v1/targets?state=active', { cache: 'no-store', credentials: 'same-origin' }); const panels = scrapePoolList.find(ScrapePoolPanel); expect(panels).toHaveLength(3); const activeTargets: Target[] = sampleApiResponse.data.activeTargets as Target[]; @@ -74,7 +58,7 @@ describe('ScrapePoolList', () => { scrapePoolList = mount(); }); scrapePoolList.update(); - expect(mock).toHaveBeenCalledWith('../api/v1/targets?state=active', { cache: 'no-cache', credentials: 'same-origin' }); + expect(mock).toHaveBeenCalledWith('../api/v1/targets?state=active', { cache: 'no-store', credentials: 'same-origin' }); const panels = scrapePoolList.find(ScrapePoolPanel); expect(panels).toHaveLength(0); }); @@ -90,7 +74,7 @@ describe('ScrapePoolList', () => { }); scrapePoolList.update(); - expect(mock).toHaveBeenCalledWith('../api/v1/targets?state=active', { cache: 'no-cache', credentials: 'same-origin' }); + expect(mock).toHaveBeenCalledWith('../api/v1/targets?state=active', { cache: 'no-store', credentials: 'same-origin' }); const alert = scrapePoolList.find(Alert); expect(alert.prop('color')).toBe('danger'); expect(alert.text()).toContain('Error fetching targets'); diff --git a/web/ui/react-app/src/pages/targets/ScrapePoolList.tsx b/web/ui/react-app/src/pages/targets/ScrapePoolList.tsx index 051ecfd062a..7be82f1fcee 100644 --- a/web/ui/react-app/src/pages/targets/ScrapePoolList.tsx +++ b/web/ui/react-app/src/pages/targets/ScrapePoolList.tsx @@ -1,60 +1,48 @@ import React, { FC } from 'react'; import { FilterData } from './Filter'; import { useFetch } from '../../hooks/useFetch'; -import { ScrapePool, groupTargets, Target } from './target'; +import { groupTargets, Target } from './target'; import ScrapePoolPanel from './ScrapePoolPanel'; import PathPrefixProps from '../../types/PathPrefixProps'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faSpinner } from '@fortawesome/free-solid-svg-icons'; -import { Alert } from 'reactstrap'; - -interface TargetsResponse { - activeTargets: Target[]; - droppedTargets: Target[]; -} +import { withStatusIndicator } from '../../components/withStatusIndicator'; interface ScrapePoolListProps { filter: FilterData; + activeTargets: Target[]; } -const filterByHealth = ({ upCount, targets }: ScrapePool, { showHealthy, showUnhealthy }: FilterData): boolean => { - const isHealthy = upCount === targets.length; - return (isHealthy && showHealthy) || (!isHealthy && showUnhealthy); +export const ScrapePoolContent: FC = ({ filter, activeTargets }) => { + const targetGroups = groupTargets(activeTargets); + const { showHealthy, showUnhealthy } = filter; + return ( + <> + {Object.keys(targetGroups).reduce((panels, scrapePool) => { + const targetGroup = targetGroups[scrapePool]; + const isHealthy = targetGroup.upCount === targetGroup.targets.length; + return (isHealthy && showHealthy) || (!isHealthy && showUnhealthy) + ? [...panels, ] + : panels; + }, [])} + + ); }; +ScrapePoolContent.displayName = 'ScrapePoolContent'; -const ScrapePoolList: FC = ({ filter, pathPrefix }) => { - const { response, error } = useFetch(`${pathPrefix}/api/v1/targets?state=active`); +const ScrapePoolListWithStatusIndicator = withStatusIndicator(ScrapePoolContent); - if (error) { - return ( - - Error fetching targets: {error.message} - - ); - } else if (response && response.status !== 'success' && response.status !== 'start fetching') { - return ( - - Error fetching targets: {response.status} - - ); - } else if (response && response.data) { - const { activeTargets } = response.data; - const targetGroups = groupTargets(activeTargets); - return ( - <> - {Object.keys(targetGroups) - .filter((scrapePool: string) => filterByHealth(targetGroups[scrapePool], filter)) - .map((scrapePool: string) => { - const targetGroupProps = { - scrapePool, - targetGroup: targetGroups[scrapePool], - }; - return ; - })} - - ); - } - return ; +const ScrapePoolList: FC<{ filter: FilterData } & PathPrefixProps> = ({ pathPrefix, filter }) => { + const { response, error, isLoading } = useFetch(`${pathPrefix}/api/v1/targets?state=active`); + const { status: responseStatus } = response; + const badResponse = responseStatus !== 'success' && responseStatus !== 'start fetching'; + return ( + + ); }; export default ScrapePoolList; diff --git a/web/ui/react-app/src/pages/tsdbStatus/TSDBStatus.test.tsx b/web/ui/react-app/src/pages/tsdbStatus/TSDBStatus.test.tsx index 737e8949335..828c3054df5 100644 --- a/web/ui/react-app/src/pages/tsdbStatus/TSDBStatus.test.tsx +++ b/web/ui/react-app/src/pages/tsdbStatus/TSDBStatus.test.tsx @@ -1,10 +1,9 @@ import * as React from 'react'; -import { mount, shallow, ReactWrapper } from 'enzyme'; +import { mount, ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; -import { Alert, Table } from 'reactstrap'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faSpinner } from '@fortawesome/free-solid-svg-icons'; -import { TSDBStatus } from '..'; +import { Table } from 'reactstrap'; + +import TSDBStatus from './TSDBStatus'; import { TSDBMap } from './TSDBStatus'; const fakeTSDBStatusResponse: { @@ -49,33 +48,6 @@ describe('TSDB Stats', () => { fetchMock.resetMocks(); }); - it('before data is returned', () => { - const tsdbStatus = shallow(); - const icon = tsdbStatus.find(FontAwesomeIcon); - expect(icon.prop('icon')).toEqual(faSpinner); - expect(icon.prop('spin')).toBeTruthy(); - }); - - describe('when an error is returned', () => { - it('displays an alert', async () => { - const mock = fetchMock.mockReject(new Error('error loading tsdb status')); - - let page: any; - await act(async () => { - page = mount(); - }); - page.update(); - - expect(mock).toHaveBeenCalledWith('/path/prefix/api/v1/status/tsdb', { - cache: 'no-cache', - credentials: 'same-origin', - }); - const alert = page.find(Alert); - expect(alert.prop('color')).toBe('danger'); - expect(alert.text()).toContain('error loading tsdb status'); - }); - }); - describe('Table Data Validation', () => { it('Table Test', async () => { const tables = [ @@ -105,7 +77,7 @@ describe('TSDB Stats', () => { page.update(); expect(mock).toHaveBeenCalledWith('/path/prefix/api/v1/status/tsdb', { - cache: 'no-cache', + cache: 'no-store', credentials: 'same-origin', }); diff --git a/web/ui/react-app/src/pages/tsdbStatus/TSDBStatus.tsx b/web/ui/react-app/src/pages/tsdbStatus/TSDBStatus.tsx index 37df8f743c5..111787f9675 100644 --- a/web/ui/react-app/src/pages/tsdbStatus/TSDBStatus.tsx +++ b/web/ui/react-app/src/pages/tsdbStatus/TSDBStatus.tsx @@ -1,86 +1,80 @@ -import React, { FC, Fragment } from 'react'; +import React, { FC } from 'react'; import { RouteComponentProps } from '@reach/router'; -import { Alert, Table } from 'reactstrap'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faSpinner } from '@fortawesome/free-solid-svg-icons'; +import { Table } from 'reactstrap'; + import { useFetch } from '../../hooks/useFetch'; import PathPrefixProps from '../../types/PathPrefixProps'; +import { withStatusIndicator } from '../../components/withStatusIndicator'; -export interface Stats { +interface Stats { name: string; value: number; } export interface TSDBMap { - seriesCountByMetricName: Array; - labelValueCountByLabelName: Array; - memoryInBytesByLabelName: Array; - seriesCountByLabelValuePair: Array; + seriesCountByMetricName: Stats[]; + labelValueCountByLabelName: Stats[]; + memoryInBytesByLabelName: Stats[]; + seriesCountByLabelValuePair: Stats[]; } -const paddingStyle = { - padding: '10px', -}; - -function createTable(title: string, unit: string, stats: Array) { +export const TSDBStatusContent: FC = ({ + labelValueCountByLabelName, + seriesCountByMetricName, + memoryInBytesByLabelName, + seriesCountByLabelValuePair, +}) => { return ( -
-

{title}

-
- Endpoint -
- - https://1.2.3.4:9093 - - /api/v1/alerts -
- - https://1.2.3.5:9093 - - /api/v1/alerts -
- - https://1.2.3.6:9093 - - /api/v1/alerts -
- - https://1.2.3.7:9093 - - /api/v1/alerts -
- - https://1.2.3.8:9093 - - /api/v1/alerts -
- - https://1.2.3.9:9093 - - /api/v1/alerts +
- - - - - - - - {stats.map((element: Stats, i: number) => { - return ( - +
+

TSDB Status

+

Head Cardinality Stats

+ {[ + { title: 'Top 10 label names with value count', stats: labelValueCountByLabelName }, + { title: 'Top 10 series count by metric names', stats: seriesCountByMetricName }, + { title: 'Top 10 label names with high memory usage', unit: 'Bytes', stats: memoryInBytesByLabelName }, + { title: 'Top 10 series count by label value pairs', stats: seriesCountByLabelValuePair }, + ].map(({ title, unit = 'Count', stats }) => { + return ( +
+

{title}

+
Name{unit}
+ - - + + - - ); - })} - -
{element.name}{element.value}Name{unit}
+ + + {stats.map(({ name, value }) => { + return ( + + {name} + {value} + + ); + })} + + + + ); + })} ); -} +}; +TSDBStatusContent.displayName = 'TSDBStatusContent'; + +const TSDBStatusContentWithStatusIndicator = withStatusIndicator(TSDBStatusContent); const TSDBStatus: FC = ({ pathPrefix }) => { - const { response, error } = useFetch(`${pathPrefix}/api/v1/status/tsdb`); - const headStats = () => { - const stats = response && response.data; - if (error) { - return ( - - Error: Error fetching TSDB Status: {error.message} - - ); - } else if (stats) { - return ( -
-
-

Head Cardinality Stats

-
- {createTable('Top 10 label names with value count', 'Count', stats.labelValueCountByLabelName)} - {createTable('Top 10 series count by metric names', 'Count', stats.seriesCountByMetricName)} - {createTable('Top 10 label names with high memory usage', 'Bytes', stats.memoryInBytesByLabelName)} - {createTable('Top 10 series count by label value pairs', 'Count', stats.seriesCountByLabelValuePair)} -
- ); - } - return ; - }; + const { response, error, isLoading } = useFetch(`${pathPrefix}/api/v1/status/tsdb`); return ( -
-

TSDB Status

- {headStats()} -
+ ); }; diff --git a/web/ui/react-app/src/utils/index.ts b/web/ui/react-app/src/utils/index.ts index 82915f0b78a..ebcf1b62430 100644 --- a/web/ui/react-app/src/utils/index.ts +++ b/web/ui/react-app/src/utils/index.ts @@ -201,3 +201,12 @@ export const encodePanelOptionsToQueryString = (panels: PanelMeta[]) => { export const createExpressionLink = (expr: string) => { return `../graph?g0.expr=${encodeURIComponent(expr)}&g0.tab=1&g0.stacked=0&g0.range_input=1h`; }; +export const mapObjEntries = ( + o: T, + cb: ([k, v]: [string, T[key]], i: number, arr: [string, T[key]][]) => Z +) => Object.entries(o).map(cb); + +export const callAll = (...fns: Array<(...args: any) => void>) => (...args: any) => { + // eslint-disable-next-line prefer-spread + fns.filter(Boolean).forEach(fn => fn.apply(null, args)); +};