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
Boyko 2020-02-03 16:14:25 +02:00 committed by GitHub
parent 820d7775eb
commit 8c2bc2f57a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 422 additions and 923 deletions

View File

@ -7,6 +7,7 @@
],
"rules": {
"@typescript-eslint/camelcase": "warn",
"@typescript-eslint/explicit-function-return-type": ["off"],
"eol-last": [
"error",
"always"

View File

@ -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');

View File

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

View File

@ -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>

View File

@ -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);
}

View File

@ -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);

View File

@ -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;

View File

@ -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');
});

View File

@ -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;

View File

@ -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 };

View File

@ -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;

View File

@ -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();
});

View File

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

View File

@ -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>

View File

@ -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');

View File

@ -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;

View File

@ -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',
});

View File

@ -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;

View File

@ -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));
};