Unify react fetcher components (#6629)
* set useFetch loading flag to be true initially Signed-off-by: blalov <boiskila@gmail.com> * make extended props optional Signed-off-by: blalov <boiskila@gmail.com> * add status indicator to targets page Signed-off-by: blalov <boiskila@gmail.com> * add status indicator to tsdb status page Signed-off-by: blalov <boiskila@gmail.com> * spread response in Alerts Signed-off-by: blalov <boiskila@gmail.com> * disable eslint func retun type rule Signed-off-by: blalov <boiskila@gmail.com> * add status indicator to Service Discovery page Signed-off-by: blalov <boiskila@gmail.com> * refactor PanelList Signed-off-by: blalov <boiskila@gmail.com> * test fix Signed-off-by: blalov <boiskila@gmail.com> * use local storage hook in PanelList Signed-off-by: blalov <boiskila@gmail.com> * use 'useFetch' for fetching metrics Signed-off-by: blalov <boiskila@gmail.com> * left-overs Signed-off-by: blalov <boiskila@gmail.com> * remove targets page custom error message Signed-off-by: Boyko Lalov <boiskila@gmail.com> * adding components displayName Signed-off-by: Boyko Lalov <boiskila@gmail.com> * display more user friendly error messages Signed-off-by: Boyko Lalov <boiskila@gmail.com> * update status page snapshot Signed-off-by: Boyko Lalov <boiskila@gmail.com> * pr review changes Signed-off-by: Boyko Lalov <boiskila@gmail.com> * fix broken tests Signed-off-by: Boyko Lalov <boiskila@gmail.com> * fix typos Signed-off-by: Boyko Lalov <boiskila@gmail.com>pull/6760/head
parent
820d7775eb
commit
8c2bc2f57a
@ -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<RouteComponentProps & PathPrefixProps, PanelListState> { |
||||
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<PanelListProps> = ({ |
||||
metrics = [], |
||||
useLocalTime, |
||||
pathPrefix, |
||||
queryHistoryEnabled, |
||||
...rest |
||||
}) => { |
||||
const [panels, setPanels] = useState(rest.panels); |
||||
const [historyItems, setLocalStorageHistoryItems] = useLocalStorage<string[]>('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<HTMLInputElement>) => { |
||||
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<HTMLInputElement>) => { |
||||
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<PanelMeta[]>((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 ( |
||||
<> |
||||
<Row className="mb-2"> |
||||
<Checkbox |
||||
id="query-history-checkbox" |
||||
wrapperStyles={{ margin: '0 0 0 15px', alignSelf: 'center' }} |
||||
onChange={this.toggleQueryHistory} |
||||
defaultChecked={this.isHistoryEnabled()} |
||||
> |
||||
Enable query history |
||||
</Checkbox> |
||||
<Checkbox |
||||
id="use-local-time-checkbox" |
||||
wrapperStyles={{ margin: '0 0 0 15px', alignSelf: 'center' }} |
||||
onChange={this.toggleUseLocalTime} |
||||
defaultChecked={this.useLocalTime()} |
||||
> |
||||
Use local time |
||||
</Checkbox> |
||||
</Row> |
||||
<Row> |
||||
<Col> |
||||
{timeDriftError && ( |
||||
<Alert color="danger"> |
||||
<strong>Warning:</strong> Error fetching server time: {timeDriftError} |
||||
</Alert> |
||||
)} |
||||
</Col> |
||||
</Row> |
||||
<Row> |
||||
<Col> |
||||
{fetchMetricsError && ( |
||||
<Alert color="danger"> |
||||
<strong>Warning:</strong> Error fetching metrics list: {fetchMetricsError} |
||||
</Alert> |
||||
)} |
||||
</Col> |
||||
</Row> |
||||
{panels.map(({ id, options }) => ( |
||||
<Panel |
||||
onExecuteQuery={this.handleExecuteQuery} |
||||
key={id} |
||||
options={options} |
||||
onOptionsChanged={opts => this.handleOptionsChanged(id, opts)} |
||||
useLocalTime={this.state.useLocalTime} |
||||
removePanel={() => this.removePanel(id)} |
||||
metricNames={metricNames} |
||||
pastQueries={pastQueries} |
||||
pathPrefix={pathPrefix} |
||||
/> |
||||
))} |
||||
<Button color="primary" className="add-panel-btn" onClick={this.addPanel}> |
||||
Add Panel |
||||
</Button> |
||||
</> |
||||
); |
||||
} |
||||
} |
||||
return ( |
||||
<> |
||||
{panels.map(({ id, options }) => ( |
||||
<Panel |
||||
onExecuteQuery={handleExecuteQuery} |
||||
key={id} |
||||
options={options} |
||||
onOptionsChanged={opts => |
||||
callAll(setPanels, updateURL)(panels.map(p => (id === p.id ? { ...p, options: opts } : p))) |
||||
} |
||||
removePanel={() => |
||||
callAll(setPanels, updateURL)( |
||||
panels.reduce<PanelMeta[]>( |
||||
(acc, panel) => (panel.id !== id ? [...acc, { ...panel, key: `${acc.length}` }] : acc), |
||||
[] |
||||
) |
||||
) |
||||
} |
||||
useLocalTime={useLocalTime} |
||||
metricNames={metrics} |
||||
pastQueries={queryHistoryEnabled ? historyItems : []} |
||||
pathPrefix={pathPrefix} |
||||
/> |
||||
))} |
||||
<Button className="mb-3" color="primary" onClick={addPanel}> |
||||
Add Panel |
||||
</Button> |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
const PanelList: FC<RouteComponentProps & PathPrefixProps> = ({ 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<string[]>(`${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 ( |
||||
<> |
||||
<Checkbox |
||||
wrapperStyles={{ marginLeft: 3, display: 'inline-block' }} |
||||
id="query-history-checkbox" |
||||
onChange={({ target }) => setEnableQueryHistory(target.checked)} |
||||
defaultChecked={enableQueryHistory} |
||||
> |
||||
Enable query history |
||||
</Checkbox> |
||||
<Checkbox |
||||
wrapperStyles={{ marginLeft: 20, display: 'inline-block' }} |
||||
id="use-local-time-checkbox" |
||||
onChange={({ target }) => setUseLocalTime(target.checked)} |
||||
defaultChecked={useLocalTime} |
||||
> |
||||
Use local time |
||||
</Checkbox> |
||||
{(delta > 30 || timeErr) && ( |
||||
<Alert color="danger"> |
||||
<strong>Warning: </strong> |
||||
{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.`} |
||||
</Alert> |
||||
)} |
||||
{metricsErr && ( |
||||
<Alert color="danger"> |
||||
<strong>Warning: </strong> |
||||
Error fetching metrics list: Unexpected response status when fetching metric names: {metricsErr.message} |
||||
</Alert> |
||||
)} |
||||
<PanelListContent |
||||
panels={decodePanelOptionsFromQueryString(window.location.search)} |
||||
pathPrefix={pathPrefix} |
||||
useLocalTime={useLocalTime} |
||||
metrics={metricsRes.data} |
||||
queryHistoryEnabled={enableQueryHistory} |
||||
/> |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
export default PanelList; |
||||
|
||||
@ -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<ScrapePoolListProps> = ({ filter, activeTargets }) => { |
||||
const targetGroups = groupTargets(activeTargets); |
||||
const { showHealthy, showUnhealthy } = filter; |
||||
return ( |
||||
<> |
||||
{Object.keys(targetGroups).reduce<JSX.Element[]>((panels, scrapePool) => { |
||||
const targetGroup = targetGroups[scrapePool]; |
||||
const isHealthy = targetGroup.upCount === targetGroup.targets.length; |
||||
return (isHealthy && showHealthy) || (!isHealthy && showUnhealthy) |
||||
? [...panels, <ScrapePoolPanel key={scrapePool} scrapePool={scrapePool} targetGroup={targetGroup} />] |
||||
: panels; |
||||
}, [])} |
||||
</> |
||||
); |
||||
}; |
||||
ScrapePoolContent.displayName = 'ScrapePoolContent'; |
||||
|
||||
const ScrapePoolList: FC<ScrapePoolListProps & PathPrefixProps> = ({ filter, pathPrefix }) => { |
||||
const { response, error } = useFetch<TargetsResponse>(`${pathPrefix}/api/v1/targets?state=active`); |
||||
const ScrapePoolListWithStatusIndicator = withStatusIndicator(ScrapePoolContent); |
||||
|
||||
if (error) { |
||||
return ( |
||||
<Alert color="danger"> |
||||
<strong>Error fetching targets:</strong> {error.message} |
||||
</Alert> |
||||
); |
||||
} else if (response && response.status !== 'success' && response.status !== 'start fetching') { |
||||
return ( |
||||
<Alert color="danger"> |
||||
<strong>Error fetching targets:</strong> {response.status} |
||||
</Alert> |
||||
); |
||||
} 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 <ScrapePoolPanel key={scrapePool} {...targetGroupProps} />; |
||||
})} |
||||
</> |
||||
); |
||||
} |
||||
return <FontAwesomeIcon icon={faSpinner} spin />; |
||||
const ScrapePoolList: FC<{ filter: FilterData } & PathPrefixProps> = ({ pathPrefix, filter }) => { |
||||
const { response, error, isLoading } = useFetch<ScrapePoolListProps>(`${pathPrefix}/api/v1/targets?state=active`); |
||||
const { status: responseStatus } = response; |
||||
const badResponse = responseStatus !== 'success' && responseStatus !== 'start fetching'; |
||||
return ( |
||||
<ScrapePoolListWithStatusIndicator |
||||
{...response.data} |
||||
filter={filter} |
||||
error={badResponse ? new Error(responseStatus) : error} |
||||
isLoading={isLoading} |
||||
componentTitle="Targets information" |
||||
/> |
||||
); |
||||
}; |
||||
|
||||
export default ScrapePoolList; |
||||
|
||||
Loading…
Reference in new issue