mirror of https://github.com/prometheus/prometheus
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
|
@ -7,6 +7,7 @@
|
|||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/camelcase": "warn",
|
||||
"@typescript-eslint/explicit-function-return-type": ["off"],
|
||||
"eol-last": [
|
||||
"error",
|
||||
"always"
|
||||
|
|
|
@ -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(<App pathPrefix="/path/prefix" />);
|
||||
|
@ -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');
|
||||
|
|
|
@ -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<PathPrefixProps> = ({ pathPrefix }) => {
|
||||
|
@ -24,7 +24,7 @@ const App: FC<PathPrefixProps> = ({ pathPrefix }) => {
|
|||
<Config path="/config" pathPrefix={pathPrefix} />
|
||||
<Flags path="/flags" pathPrefix={pathPrefix} />
|
||||
<Rules path="/rules" pathPrefix={pathPrefix} />
|
||||
<Services path="/service-discovery" pathPrefix={pathPrefix} />
|
||||
<ServiceDiscovery path="/service-discovery" pathPrefix={pathPrefix} />
|
||||
<Status path="/status" pathPrefix={pathPrefix} />
|
||||
<TSDBStatus path="/tsdb-status" pathPrefix={pathPrefix} />
|
||||
<Targets path="/targets" pathPrefix={pathPrefix} />
|
||||
|
|
|
@ -7,12 +7,14 @@ interface StatusIndicatorProps {
|
|||
error?: Error;
|
||||
isLoading?: boolean;
|
||||
customErrorMsg?: JSX.Element;
|
||||
componentTitle?: string;
|
||||
}
|
||||
|
||||
export const withStatusIndicator = <T extends {}>(Component: ComponentType<T>): FC<StatusIndicatorProps & T> => ({
|
||||
error,
|
||||
isLoading,
|
||||
customErrorMsg,
|
||||
componentTitle,
|
||||
...rest
|
||||
}) => {
|
||||
if (error) {
|
||||
|
@ -22,7 +24,7 @@ export const withStatusIndicator = <T extends {}>(Component: ComponentType<T>):
|
|||
customErrorMsg
|
||||
) : (
|
||||
<>
|
||||
<strong>Error:</strong> Error fetching {Component.displayName}: {error.message}
|
||||
<strong>Error:</strong> Error fetching {componentTitle || Component.displayName}: {error.message}
|
||||
</>
|
||||
)}
|
||||
</Alert>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
|
||||
export type APIResponse<T> = { status: string; data?: T };
|
||||
export type APIResponse<T> = { status: string; data: T };
|
||||
|
||||
export interface FetchState<T> {
|
||||
response: APIResponse<T>;
|
||||
|
@ -9,15 +9,15 @@ export interface FetchState<T> {
|
|||
}
|
||||
|
||||
export const useFetch = <T extends {}>(url: string, options?: RequestInit): FetchState<T> => {
|
||||
const [response, setResponse] = useState<APIResponse<T>>({ status: 'start fetching' });
|
||||
const [response, setResponse] = useState<APIResponse<T>>({ status: 'start fetching' } as any);
|
||||
const [error, setError] = useState<Error>();
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(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);
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
|
||||
|
||||
export function useLocalStorage<S>(localStorageKey: string, initialState: S): [S, Dispatch<SetStateAction<S>>] {
|
||||
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);
|
||||
|
|
|
@ -20,14 +20,7 @@ const Alerts: FC<RouteComponentProps & PathPrefixProps> = ({ pathPrefix = '' })
|
|||
response.data.groups.forEach(el => el.rules.forEach(r => ruleStatsCount[r.state]++));
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertsWithStatusIndicator
|
||||
statsCount={ruleStatsCount}
|
||||
groups={response.data && response.data.groups}
|
||||
error={error}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
return <AlertsWithStatusIndicator statsCount={ruleStatsCount} {...response.data} error={error} isLoading={isLoading} />;
|
||||
};
|
||||
|
||||
export default Alerts;
|
||||
|
|
|
@ -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(<PanelList />);
|
||||
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(<PanelList />);
|
||||
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(<PanelList />);
|
||||
const panelList = shallow(<PanelListContent {...({ panels: [{}] } as any)} />);
|
||||
const panels = panelList.find(Panel);
|
||||
expect(panels.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders a button to add a panel', () => {
|
||||
const panelList = shallow(<PanelList />);
|
||||
const btn = panelList.find(Button).filterWhere(btn => btn.prop('className') === 'add-panel-btn');
|
||||
const panelList = shallow(<PanelListContent {...({ panels: [] } as any)} />);
|
||||
const btn = panelList.find(Button);
|
||||
expect(btn.prop('color')).toEqual('primary');
|
||||
expect(btn.children().text()).toEqual('Add Panel');
|
||||
});
|
||||
|
|
|
@ -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
|
||||
);
|
||||
};
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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<RouteComponentProps & PathPrefixProps> = ({ pathPrefix }) => {
|
||||
const { response, error } = useFetch<ServiceMap>(`${pathPrefix}/api/v1/targets`);
|
||||
export const processSummary = (activeTargets: Target[], droppedTargets: DroppedTarget[]) => {
|
||||
const targets: Record<string, { active: number; total: number }> = {};
|
||||
|
||||
const processSummary = (response: ServiceMap) => {
|
||||
const targets: any = {};
|
||||
|
||||
// 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++;
|
||||
}
|
||||
|
||||
return targets;
|
||||
};
|
||||
|
||||
const processTargets = (response: Target[], dropped: DroppedTarget[]) => {
|
||||
const labels: Record<string, TargetLabels[]> = {};
|
||||
|
||||
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 dropped) {
|
||||
const { job: name } = target.discoveredLabels;
|
||||
if (!labels[name]) {
|
||||
labels[name] = [];
|
||||
}
|
||||
labels[name].push({
|
||||
discoveredLabels: target.discoveredLabels,
|
||||
isDropped: true,
|
||||
labels: {},
|
||||
});
|
||||
}
|
||||
|
||||
return labels;
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert color="danger">
|
||||
<strong>Error:</strong> Error fetching Service-Discovery: {error.message}
|
||||
</Alert>
|
||||
);
|
||||
} else if (response.data) {
|
||||
const targets = processSummary(response.data);
|
||||
const labels = processTargets(response.data.activeTargets, response.data.droppedTargets);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Service Discovery</h2>
|
||||
<ul>
|
||||
{Object.keys(targets).map((val, i) => (
|
||||
<li key={i}>
|
||||
<a href={'#' + val}>
|
||||
{' '}
|
||||
{val} ({targets[val].active} / {targets[val].total} active targets){' '}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<hr />
|
||||
{Object.keys(labels).map((val: any, i) => {
|
||||
const value = labels[val];
|
||||
return <LabelsTable value={value} name={val} key={Object.keys(labels)[i]} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
targets[name].total++;
|
||||
targets[name].active++;
|
||||
}
|
||||
return <FontAwesomeIcon icon={faSpinner} spin />;
|
||||
for (const target of droppedTargets) {
|
||||
const { job: name } = target.discoveredLabels;
|
||||
if (!targets[name]) {
|
||||
targets[name] = {
|
||||
total: 0,
|
||||
active: 0,
|
||||
};
|
||||
}
|
||||
targets[name].total++;
|
||||
}
|
||||
|
||||
return targets;
|
||||
};
|
||||
|
||||
export default Services;
|
||||
export const processTargets = (activeTargets: Target[], droppedTargets: DroppedTarget[]) => {
|
||||
const labels: Record<string, TargetLabels[]> = {};
|
||||
|
||||
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 droppedTargets) {
|
||||
const { job: name } = target.discoveredLabels;
|
||||
if (!labels[name]) {
|
||||
labels[name] = [];
|
||||
}
|
||||
labels[name].push({
|
||||
discoveredLabels: target.discoveredLabels,
|
||||
isDropped: true,
|
||||
labels: {},
|
||||
});
|
||||
}
|
||||
|
||||
return labels;
|
||||
};
|
||||
|
||||
export const ServiceDiscoveryContent: FC<ServiceMap> = ({ activeTargets, droppedTargets }) => {
|
||||
const targets = processSummary(activeTargets, droppedTargets);
|
||||
const labels = processTargets(activeTargets, droppedTargets);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Service Discovery</h2>
|
||||
<ul>
|
||||
{mapObjEntries(targets, ([k, v]) => (
|
||||
<li key={k}>
|
||||
<a href={'#' + k}>
|
||||
{k} ({v.active} / {v.total} active targets)
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<hr />
|
||||
{mapObjEntries(labels, ([k, v]) => {
|
||||
return <LabelsTable value={v} name={k} key={k} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
ServiceDiscoveryContent.displayName = 'ServiceDiscoveryContent';
|
||||
|
||||
const ServicesWithStatusIndicator = withStatusIndicator(ServiceDiscoveryContent);
|
||||
|
||||
const ServiceDiscovery: FC<RouteComponentProps & PathPrefixProps> = ({ pathPrefix }) => {
|
||||
const { response, error, isLoading } = useFetch<ServiceMap>(`${pathPrefix}/api/v1/targets`);
|
||||
return (
|
||||
<ServicesWithStatusIndicator
|
||||
{...response.data}
|
||||
error={error}
|
||||
isLoading={isLoading}
|
||||
componentTitle="Service Discovery information"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServiceDiscovery;
|
||||
|
|
|
@ -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(<StatusContent data={[]} />);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
});
|
||||
describe('Snapshot testing', () => {
|
||||
const response: any = [
|
||||
{
|
||||
|
@ -45,7 +41,7 @@ describe('Status', () => {
|
|||
},
|
||||
];
|
||||
it('should match table snapshot', () => {
|
||||
const wrapper = shallow(<StatusContent data={response} />);
|
||||
const wrapper = shallow(<StatusContent data={response} title="Foo" />);
|
||||
expect(toJson(wrapper)).toMatchSnapshot();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
|
|
@ -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<string, string>;
|
||||
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<StatusPageProps> = ({ data = [] }) => {
|
||||
export const StatusContent: FC<StatusPageProps> = ({ data, title }) => {
|
||||
return (
|
||||
<>
|
||||
{data.map((statuses, i) => {
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
<h2>{sectionTitles[i]}</h2>
|
||||
<Table className="h-auto" size="sm" bordered striped>
|
||||
<tbody>
|
||||
{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 (
|
||||
<tr key={k}>
|
||||
<th className="capitalize-title" style={{ width: '35%' }}>
|
||||
{title}
|
||||
</th>
|
||||
<td className="text-break">{customizeValue(v, title)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
<h2>{title}</h2>
|
||||
<Table className="h-auto" size="sm" bordered striped>
|
||||
<tbody>
|
||||
{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 (
|
||||
<tr key={k}>
|
||||
<th className="capitalize-title" style={{ width: '35%' }}>
|
||||
{title}
|
||||
</th>
|
||||
<td className="text-break">{customizeValue(v, title)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -96,21 +86,26 @@ StatusContent.displayName = 'Status';
|
|||
|
||||
const Status: FC<RouteComponentProps & PathPrefixProps> = ({ pathPrefix = '' }) => {
|
||||
const path = `${pathPrefix}/api/v1`;
|
||||
const status = useFetch<StatusPageState>(`${path}/status/runtimeinfo`);
|
||||
const runtime = useFetch<StatusPageState>(`${path}/status/buildinfo`);
|
||||
const build = useFetch<StatusPageState>(`${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 (
|
||||
<StatusWithStatusIndicator
|
||||
data={data}
|
||||
isLoading={status.isLoading || runtime.isLoading || build.isLoading}
|
||||
error={status.error || runtime.error || build.error}
|
||||
/>
|
||||
<>
|
||||
{[
|
||||
{ fetchResult: useFetch<Record<string, string>>(`${path}/status/runtimeinfo`), title: 'Runtime Information' },
|
||||
{ fetchResult: useFetch<Record<string, string>>(`${path}/status/buildinfo`), title: 'Build Information' },
|
||||
{ fetchResult: useFetch<Record<string, string>>(`${path}/alertmanagers`), title: 'Alertmanagers' },
|
||||
].map(({ fetchResult, title }) => {
|
||||
const { response, isLoading, error } = fetchResult;
|
||||
return (
|
||||
<StatusWithStatusIndicator
|
||||
data={response.data}
|
||||
title={title}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
componentTitle={title}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
exports[`Status Snapshot testing should match table snapshot 1`] = `
|
||||
<Fragment>
|
||||
<h2>
|
||||
Runtime Information
|
||||
Foo
|
||||
</h2>
|
||||
<Table
|
||||
bordered={true}
|
||||
|
@ -15,7 +15,7 @@ exports[`Status Snapshot testing should match table snapshot 1`] = `
|
|||
>
|
||||
<tbody>
|
||||
<tr
|
||||
key="startTime"
|
||||
key="0"
|
||||
>
|
||||
<th
|
||||
className="capitalize-title"
|
||||
|
@ -24,131 +24,17 @@ exports[`Status Snapshot testing should match table snapshot 1`] = `
|
|||
"width": "35%",
|
||||
}
|
||||
}
|
||||
>
|
||||
Start time
|
||||
</th>
|
||||
<td
|
||||
className="text-break"
|
||||
>
|
||||
Wed, 30 Oct 2019 20:03:23 GMT
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="CWD"
|
||||
>
|
||||
<th
|
||||
className="capitalize-title"
|
||||
style={
|
||||
Object {
|
||||
"width": "35%",
|
||||
}
|
||||
}
|
||||
>
|
||||
Working directory
|
||||
</th>
|
||||
<td
|
||||
className="text-break"
|
||||
>
|
||||
/home/boyskila/Desktop/prometheus
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="reloadConfigSuccess"
|
||||
>
|
||||
<th
|
||||
className="capitalize-title"
|
||||
style={
|
||||
Object {
|
||||
"width": "35%",
|
||||
}
|
||||
}
|
||||
>
|
||||
Configuration reload
|
||||
</th>
|
||||
<td
|
||||
className="text-break"
|
||||
>
|
||||
Successful
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="lastConfigTime"
|
||||
>
|
||||
<th
|
||||
className="capitalize-title"
|
||||
style={
|
||||
Object {
|
||||
"width": "35%",
|
||||
}
|
||||
}
|
||||
>
|
||||
Last successful configuration reload
|
||||
</th>
|
||||
<td
|
||||
className="text-break"
|
||||
>
|
||||
2019-10-30T22:03:23+02:00
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="chunkCount"
|
||||
>
|
||||
<th
|
||||
className="capitalize-title"
|
||||
style={
|
||||
Object {
|
||||
"width": "35%",
|
||||
}
|
||||
}
|
||||
>
|
||||
Head chunks
|
||||
</th>
|
||||
<td
|
||||
className="text-break"
|
||||
>
|
||||
1383
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="timeSeriesCount"
|
||||
>
|
||||
<th
|
||||
className="capitalize-title"
|
||||
style={
|
||||
Object {
|
||||
"width": "35%",
|
||||
}
|
||||
}
|
||||
>
|
||||
Head time series
|
||||
</th>
|
||||
<td
|
||||
className="text-break"
|
||||
>
|
||||
461
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="corruptionCount"
|
||||
>
|
||||
<th
|
||||
className="capitalize-title"
|
||||
style={
|
||||
Object {
|
||||
"width": "35%",
|
||||
}
|
||||
}
|
||||
>
|
||||
WAL corruptions
|
||||
</th>
|
||||
<td
|
||||
className="text-break"
|
||||
>
|
||||
0
|
||||
</th>
|
||||
<td
|
||||
className="text-break"
|
||||
>
|
||||
<Component />
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="goroutineCount"
|
||||
key="1"
|
||||
>
|
||||
<th
|
||||
className="capitalize-title"
|
||||
|
@ -158,16 +44,16 @@ exports[`Status Snapshot testing should match table snapshot 1`] = `
|
|||
}
|
||||
}
|
||||
>
|
||||
Goroutines
|
||||
1
|
||||
</th>
|
||||
<td
|
||||
className="text-break"
|
||||
>
|
||||
37
|
||||
<Component />
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="GOMAXPROCS"
|
||||
key="2"
|
||||
>
|
||||
<th
|
||||
className="capitalize-title"
|
||||
|
@ -177,274 +63,12 @@ exports[`Status Snapshot testing should match table snapshot 1`] = `
|
|||
}
|
||||
}
|
||||
>
|
||||
GOMAXPROCS
|
||||
2
|
||||
</th>
|
||||
<td
|
||||
className="text-break"
|
||||
>
|
||||
4
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="GOGC"
|
||||
>
|
||||
<th
|
||||
className="capitalize-title"
|
||||
style={
|
||||
Object {
|
||||
"width": "35%",
|
||||
}
|
||||
}
|
||||
>
|
||||
GOGC
|
||||
</th>
|
||||
<td
|
||||
className="text-break"
|
||||
/>
|
||||
</tr>
|
||||
<tr
|
||||
key="GODEBUG"
|
||||
>
|
||||
<th
|
||||
className="capitalize-title"
|
||||
style={
|
||||
Object {
|
||||
"width": "35%",
|
||||
}
|
||||
}
|
||||
>
|
||||
GODEBUG
|
||||
</th>
|
||||
<td
|
||||
className="text-break"
|
||||
/>
|
||||
</tr>
|
||||
<tr
|
||||
key="storageRetention"
|
||||
>
|
||||
<th
|
||||
className="capitalize-title"
|
||||
style={
|
||||
Object {
|
||||
"width": "35%",
|
||||
}
|
||||
}
|
||||
>
|
||||
Storage retention
|
||||
</th>
|
||||
<td
|
||||
className="text-break"
|
||||
>
|
||||
15d
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
<h2>
|
||||
Build Information
|
||||
</h2>
|
||||
<Table
|
||||
bordered={true}
|
||||
className="h-auto"
|
||||
responsiveTag="div"
|
||||
size="sm"
|
||||
striped={true}
|
||||
tag="table"
|
||||
>
|
||||
<tbody>
|
||||
<tr
|
||||
key="version"
|
||||
>
|
||||
<th
|
||||
className="capitalize-title"
|
||||
style={
|
||||
Object {
|
||||
"width": "35%",
|
||||
}
|
||||
}
|
||||
>
|
||||
version
|
||||
</th>
|
||||
<td
|
||||
className="text-break"
|
||||
/>
|
||||
</tr>
|
||||
<tr
|
||||
key="revision"
|
||||
>
|
||||
<th
|
||||
className="capitalize-title"
|
||||
style={
|
||||
Object {
|
||||
"width": "35%",
|
||||
}
|
||||
}
|
||||
>
|
||||
revision
|
||||
</th>
|
||||
<td
|
||||
className="text-break"
|
||||
/>
|
||||
</tr>
|
||||
<tr
|
||||
key="branch"
|
||||
>
|
||||
<th
|
||||
className="capitalize-title"
|
||||
style={
|
||||
Object {
|
||||
"width": "35%",
|
||||
}
|
||||
}
|
||||
>
|
||||
branch
|
||||
</th>
|
||||
<td
|
||||
className="text-break"
|
||||
/>
|
||||
</tr>
|
||||
<tr
|
||||
key="buildUser"
|
||||
>
|
||||
<th
|
||||
className="capitalize-title"
|
||||
style={
|
||||
Object {
|
||||
"width": "35%",
|
||||
}
|
||||
}
|
||||
>
|
||||
buildUser
|
||||
</th>
|
||||
<td
|
||||
className="text-break"
|
||||
/>
|
||||
</tr>
|
||||
<tr
|
||||
key="buildDate"
|
||||
>
|
||||
<th
|
||||
className="capitalize-title"
|
||||
style={
|
||||
Object {
|
||||
"width": "35%",
|
||||
}
|
||||
}
|
||||
>
|
||||
buildDate
|
||||
</th>
|
||||
<td
|
||||
className="text-break"
|
||||
/>
|
||||
</tr>
|
||||
<tr
|
||||
key="goVersion"
|
||||
>
|
||||
<th
|
||||
className="capitalize-title"
|
||||
style={
|
||||
Object {
|
||||
"width": "35%",
|
||||
}
|
||||
}
|
||||
>
|
||||
goVersion
|
||||
</th>
|
||||
<td
|
||||
className="text-break"
|
||||
>
|
||||
go1.13.3
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
<h2>
|
||||
Alertmanagers
|
||||
</h2>
|
||||
<Table
|
||||
bordered={true}
|
||||
className="h-auto"
|
||||
responsiveTag="div"
|
||||
size="sm"
|
||||
striped={true}
|
||||
tag="table"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>
|
||||
Endpoint
|
||||
</th>
|
||||
</tr>
|
||||
<tr
|
||||
key="https://1.2.3.4:9093/api/v1/alerts"
|
||||
>
|
||||
<td>
|
||||
<a
|
||||
href="https://1.2.3.4:9093/api/v1/alerts"
|
||||
>
|
||||
https://1.2.3.4:9093
|
||||
</a>
|
||||
/api/v1/alerts
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="https://1.2.3.5:9093/api/v1/alerts"
|
||||
>
|
||||
<td>
|
||||
<a
|
||||
href="https://1.2.3.5:9093/api/v1/alerts"
|
||||
>
|
||||
https://1.2.3.5:9093
|
||||
</a>
|
||||
/api/v1/alerts
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="https://1.2.3.6:9093/api/v1/alerts"
|
||||
>
|
||||
<td>
|
||||
<a
|
||||
href="https://1.2.3.6:9093/api/v1/alerts"
|
||||
>
|
||||
https://1.2.3.6:9093
|
||||
</a>
|
||||
/api/v1/alerts
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="https://1.2.3.7:9093/api/v1/alerts"
|
||||
>
|
||||
<td>
|
||||
<a
|
||||
href="https://1.2.3.7:9093/api/v1/alerts"
|
||||
>
|
||||
https://1.2.3.7:9093
|
||||
</a>
|
||||
/api/v1/alerts
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="https://1.2.3.8:9093/api/v1/alerts"
|
||||
>
|
||||
<td>
|
||||
<a
|
||||
href="https://1.2.3.8:9093/api/v1/alerts"
|
||||
>
|
||||
https://1.2.3.8:9093
|
||||
</a>
|
||||
/api/v1/alerts
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="https://1.2.3.9:9093/api/v1/alerts"
|
||||
>
|
||||
<td>
|
||||
<a
|
||||
href="https://1.2.3.9:9093/api/v1/alerts"
|
||||
>
|
||||
https://1.2.3.9:9093
|
||||
</a>
|
||||
/api/v1/alerts
|
||||
<Component />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
|
|
@ -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(<ScrapePoolList {...defaultProps} />);
|
||||
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 {...defaultProps} />);
|
||||
});
|
||||
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 {...props} />);
|
||||
});
|
||||
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');
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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(<TSDBStatus />);
|
||||
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(<TSDBStatus pathPrefix="/path/prefix" />);
|
||||
});
|
||||
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',
|
||||
});
|
||||
|
||||
|
|
|
@ -1,87 +1,81 @@
|
|||
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<Stats>;
|
||||
labelValueCountByLabelName: Array<Stats>;
|
||||
memoryInBytesByLabelName: Array<Stats>;
|
||||
seriesCountByLabelValuePair: Array<Stats>;
|
||||
seriesCountByMetricName: Stats[];
|
||||
labelValueCountByLabelName: Stats[];
|
||||
memoryInBytesByLabelName: Stats[];
|
||||
seriesCountByLabelValuePair: Stats[];
|
||||
}
|
||||
|
||||
const paddingStyle = {
|
||||
padding: '10px',
|
||||
};
|
||||
|
||||
function createTable(title: string, unit: string, stats: Array<Stats>) {
|
||||
return (
|
||||
<div style={paddingStyle}>
|
||||
<h3>{title}</h3>
|
||||
<Table bordered={true} size="sm" striped={true}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>{unit}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.map((element: Stats, i: number) => {
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
<tr>
|
||||
<td>{element.name}</td>
|
||||
<td>{element.value}</td>
|
||||
</tr>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TSDBStatus: FC<RouteComponentProps & PathPrefixProps> = ({ pathPrefix }) => {
|
||||
const { response, error } = useFetch<TSDBMap>(`${pathPrefix}/api/v1/status/tsdb`);
|
||||
const headStats = () => {
|
||||
const stats = response && response.data;
|
||||
if (error) {
|
||||
return (
|
||||
<Alert color="danger">
|
||||
<strong>Error:</strong> Error fetching TSDB Status: {error.message}
|
||||
</Alert>
|
||||
);
|
||||
} else if (stats) {
|
||||
return (
|
||||
<div>
|
||||
<div style={paddingStyle}>
|
||||
<h3>Head Cardinality Stats</h3>
|
||||
</div>
|
||||
{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)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <FontAwesomeIcon icon={faSpinner} spin />;
|
||||
};
|
||||
|
||||
export const TSDBStatusContent: FC<TSDBMap> = ({
|
||||
labelValueCountByLabelName,
|
||||
seriesCountByMetricName,
|
||||
memoryInBytesByLabelName,
|
||||
seriesCountByLabelValuePair,
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
<h2>TSDB Status</h2>
|
||||
{headStats()}
|
||||
<h3 className="p-2">Head Cardinality Stats</h3>
|
||||
{[
|
||||
{ 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 (
|
||||
<div className="p-2" key={title}>
|
||||
<h3>{title}</h3>
|
||||
<Table bordered size="sm" striped>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>{unit}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.map(({ name, value }) => {
|
||||
return (
|
||||
<tr key={name}>
|
||||
<td>{name}</td>
|
||||
<td>{value}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
TSDBStatusContent.displayName = 'TSDBStatusContent';
|
||||
|
||||
const TSDBStatusContentWithStatusIndicator = withStatusIndicator(TSDBStatusContent);
|
||||
|
||||
const TSDBStatus: FC<RouteComponentProps & PathPrefixProps> = ({ pathPrefix }) => {
|
||||
const { response, error, isLoading } = useFetch<TSDBMap>(`${pathPrefix}/api/v1/status/tsdb`);
|
||||
|
||||
return (
|
||||
<TSDBStatusContentWithStatusIndicator
|
||||
error={error}
|
||||
isLoading={isLoading}
|
||||
{...response.data}
|
||||
componentTitle="TSDB Status information"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TSDBStatus;
|
||||
|
|
|
@ -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 = <T, key extends keyof T, Z>(
|
||||
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));
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue