mirror of
https://github.com/prometheus/prometheus.git
synced 2025-01-12 14:27:27 -08:00
Feature: Add collapse/expand all button to target page for react ui (#8486)
* Feature: Add collapse/expand all button to target page for react ui Signed-off-by: Dustin Hooten <dustinhooten@gmail.com> * update local storage key to prevent bad state Signed-off-by: Dustin Hooten <dustinhooten@gmail.com> * PR feedback Signed-off-by: Dustin Hooten <dustinhooten@gmail.com> * split big state object into smaller ones Signed-off-by: Dustin Hooten <dustinhooten@gmail.com> * fix duplication typo Signed-off-by: Dustin Hooten <dustinhooten@gmail.com>
This commit is contained in:
parent
c0c36b1155
commit
0d8db52954
|
@ -5,12 +5,23 @@ import Filter, { FilterData, FilterProps } from './Filter';
|
|||
import sinon, { SinonSpy } from 'sinon';
|
||||
|
||||
describe('Filter', () => {
|
||||
const initialState: FilterData = { showHealthy: true, showUnhealthy: true };
|
||||
const initialExpanded = {
|
||||
scrapePool1: true,
|
||||
scrapePool2: true,
|
||||
};
|
||||
let setExpaned: SinonSpy;
|
||||
const initialState: FilterData = {
|
||||
showHealthy: true,
|
||||
showUnhealthy: true,
|
||||
};
|
||||
let setFilter: SinonSpy;
|
||||
let filterWrapper: ShallowWrapper<FilterProps, Readonly<{}>, Component<{}, {}, Component>>;
|
||||
beforeEach(() => {
|
||||
setFilter = sinon.spy();
|
||||
filterWrapper = shallow(<Filter filter={initialState} setFilter={setFilter} />);
|
||||
setExpaned = sinon.spy();
|
||||
filterWrapper = shallow(
|
||||
<Filter filter={initialState} setFilter={setFilter} expanded={initialExpanded} setExpanded={setExpaned} />
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a button group', () => {
|
||||
|
@ -29,6 +40,12 @@ describe('Filter', () => {
|
|||
expect(btn.prop('color')).toBe('primary');
|
||||
});
|
||||
|
||||
it('renders an expansion filter button that is inactive', () => {
|
||||
const btn = filterWrapper.find(Button).filterWhere((btn): boolean => btn.hasClass('expansion'));
|
||||
expect(btn.prop('active')).toBe(false);
|
||||
expect(btn.prop('color')).toBe('primary');
|
||||
});
|
||||
|
||||
it('renders an all filter button which shows all targets', () => {
|
||||
const btn = filterWrapper.find(Button).filterWhere((btn): boolean => btn.hasClass('all'));
|
||||
btn.simulate('click');
|
||||
|
@ -40,6 +57,46 @@ describe('Filter', () => {
|
|||
const btn = filterWrapper.find(Button).filterWhere((btn): boolean => btn.hasClass('unhealthy'));
|
||||
btn.simulate('click');
|
||||
expect(setFilter.calledOnce).toBe(true);
|
||||
expect(setFilter.getCall(0).args[0]).toEqual({ showHealthy: false, showUnhealthy: true });
|
||||
expect(setFilter.getCall(0).args[0]).toEqual({
|
||||
showHealthy: false,
|
||||
showUnhealthy: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('Expansion filter', () => {
|
||||
[
|
||||
{
|
||||
name: 'expanded => collapsed',
|
||||
initial: initialExpanded,
|
||||
final: { scrapePool1: false, scrapePool2: false },
|
||||
text: 'Collapse All',
|
||||
},
|
||||
{
|
||||
name: 'collapsed => expanded',
|
||||
initial: { scrapePool1: false, scrapePool2: false },
|
||||
final: initialExpanded,
|
||||
text: 'Expand All',
|
||||
},
|
||||
{
|
||||
name: 'some expanded => expanded',
|
||||
initial: { scrapePool1: true, scrapePool2: false },
|
||||
final: initialExpanded,
|
||||
text: 'Expand All',
|
||||
},
|
||||
].forEach(({ name, text, initial, final }) => {
|
||||
it(`filters targets ${name}`, (): void => {
|
||||
const filter = { ...initialState };
|
||||
const filterCallback = sinon.spy();
|
||||
const expandedCallback = sinon.spy();
|
||||
const filterW = shallow(
|
||||
<Filter filter={filter} setFilter={filterCallback} expanded={initial} setExpanded={expandedCallback} />
|
||||
);
|
||||
const btn = filterW.find(Button).filterWhere((btn): boolean => btn.hasClass('expansion'));
|
||||
expect(btn.children().text()).toEqual(text);
|
||||
btn.simulate('click');
|
||||
expect(expandedCallback.calledOnce).toBe(true);
|
||||
expect(expandedCallback.getCall(0).args[0]).toEqual(final);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,13 +7,28 @@ export interface FilterData {
|
|||
showUnhealthy: boolean;
|
||||
}
|
||||
|
||||
export interface Expanded {
|
||||
[scrapePool: string]: boolean;
|
||||
}
|
||||
|
||||
export interface FilterProps {
|
||||
filter: FilterData;
|
||||
setFilter: Dispatch<SetStateAction<FilterData>>;
|
||||
expanded: Expanded;
|
||||
setExpanded: Dispatch<SetStateAction<Expanded>>;
|
||||
}
|
||||
|
||||
const Filter: FC<FilterProps> = ({ filter, setFilter }) => {
|
||||
const Filter: FC<FilterProps> = ({ filter, setFilter, expanded, setExpanded }) => {
|
||||
const { showHealthy } = filter;
|
||||
const allExpanded = Object.values(expanded).every((v: boolean): boolean => v);
|
||||
const mapExpansion = (next: boolean): Expanded =>
|
||||
Object.keys(expanded).reduce(
|
||||
(acc: { [scrapePool: string]: boolean }, scrapePool: string) => ({
|
||||
...acc,
|
||||
[scrapePool]: next,
|
||||
}),
|
||||
{}
|
||||
);
|
||||
const btnProps = {
|
||||
all: {
|
||||
active: showHealthy,
|
||||
|
@ -27,11 +42,18 @@ const Filter: FC<FilterProps> = ({ filter, setFilter }) => {
|
|||
color: 'primary',
|
||||
onClick: (): void => setFilter({ ...filter, showHealthy: false }),
|
||||
},
|
||||
expansionState: {
|
||||
active: false,
|
||||
className: `expansion ${styles.btn}`,
|
||||
color: 'primary',
|
||||
onClick: (): void => setExpanded(mapExpansion(!allExpanded)),
|
||||
},
|
||||
};
|
||||
return (
|
||||
<ButtonGroup>
|
||||
<Button {...btnProps.all}>All</Button>
|
||||
<Button {...btnProps.unhealthy}>Unhealthy</Button>
|
||||
<Button {...btnProps.expansionState}>{allExpanded ? 'Collapse All' : 'Expand All'}</Button>
|
||||
</ButtonGroup>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -10,10 +10,6 @@ import { FetchMock } from 'jest-fetch-mock/types';
|
|||
import { PathPrefixContext } from '../../contexts/PathPrefixContext';
|
||||
|
||||
describe('ScrapePoolList', () => {
|
||||
const defaultProps = {
|
||||
filter: { showHealthy: true, showUnhealthy: true },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.resetMocks();
|
||||
});
|
||||
|
@ -38,7 +34,7 @@ describe('ScrapePoolList', () => {
|
|||
await act(async () => {
|
||||
scrapePoolList = mount(
|
||||
<PathPrefixContext.Provider value="/path/prefix">
|
||||
<ScrapePoolList {...defaultProps} />
|
||||
<ScrapePoolList />
|
||||
</PathPrefixContext.Provider>
|
||||
);
|
||||
});
|
||||
|
@ -55,27 +51,6 @@ describe('ScrapePoolList', () => {
|
|||
expect(panel).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('filters by health', async () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
filter: { showHealthy: false, showUnhealthy: true },
|
||||
};
|
||||
await act(async () => {
|
||||
scrapePoolList = mount(
|
||||
<PathPrefixContext.Provider value="/path/prefix">
|
||||
<ScrapePoolList {...props} />
|
||||
</PathPrefixContext.Provider>
|
||||
);
|
||||
});
|
||||
scrapePoolList.update();
|
||||
expect(mock).toHaveBeenCalledWith('/path/prefix/api/v1/targets?state=active', {
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
const panels = scrapePoolList.find(ScrapePoolPanel);
|
||||
expect(panels).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when an error is returned', () => {
|
||||
|
@ -86,7 +61,7 @@ describe('ScrapePoolList', () => {
|
|||
await act(async () => {
|
||||
scrapePoolList = mount(
|
||||
<PathPrefixContext.Provider value="/path/prefix">
|
||||
<ScrapePoolList {...defaultProps} />
|
||||
<ScrapePoolList />
|
||||
</PathPrefixContext.Provider>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -1,29 +1,53 @@
|
|||
import React, { FC } from 'react';
|
||||
import { FilterData } from './Filter';
|
||||
import Filter, { Expanded, FilterData } from './Filter';
|
||||
import { useFetch } from '../../hooks/useFetch';
|
||||
import { groupTargets, Target } from './target';
|
||||
import ScrapePoolPanel from './ScrapePoolPanel';
|
||||
import { withStatusIndicator } from '../../components/withStatusIndicator';
|
||||
import { usePathPrefix } from '../../contexts/PathPrefixContext';
|
||||
import { API_PATH } from '../../constants/constants';
|
||||
import { useLocalStorage } from '../../hooks/useLocalStorage';
|
||||
|
||||
interface ScrapePoolListProps {
|
||||
filter: FilterData;
|
||||
activeTargets: Target[];
|
||||
}
|
||||
|
||||
export const ScrapePoolContent: FC<ScrapePoolListProps> = ({ filter, activeTargets }) => {
|
||||
export const ScrapePoolContent: FC<ScrapePoolListProps> = ({ activeTargets }) => {
|
||||
const targetGroups = groupTargets(activeTargets);
|
||||
const initialFilter: FilterData = {
|
||||
showHealthy: true,
|
||||
showUnhealthy: true,
|
||||
};
|
||||
const [filter, setFilter] = useLocalStorage('targets-page-filter', initialFilter);
|
||||
|
||||
const initialExpanded: Expanded = Object.keys(targetGroups).reduce(
|
||||
(acc: { [scrapePool: string]: boolean }, scrapePool: string) => ({
|
||||
...acc,
|
||||
[scrapePool]: true,
|
||||
}),
|
||||
{}
|
||||
);
|
||||
const [expanded, setExpanded] = useLocalStorage('targets-page-expansion-state', initialExpanded);
|
||||
|
||||
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;
|
||||
}, [])}
|
||||
<Filter filter={filter} setFilter={setFilter} expanded={expanded} setExpanded={setExpanded} />
|
||||
{Object.keys(targetGroups)
|
||||
.filter(scrapePool => {
|
||||
const targetGroup = targetGroups[scrapePool];
|
||||
const isHealthy = targetGroup.upCount === targetGroup.targets.length;
|
||||
return (isHealthy && showHealthy) || (!isHealthy && showUnhealthy);
|
||||
})
|
||||
.map<JSX.Element>(scrapePool => (
|
||||
<ScrapePoolPanel
|
||||
key={scrapePool}
|
||||
scrapePool={scrapePool}
|
||||
targetGroup={targetGroups[scrapePool]}
|
||||
expanded={expanded[scrapePool]}
|
||||
toggleExpanded={(): void => setExpanded({ ...expanded, [scrapePool]: !expanded[scrapePool] })}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -31,7 +55,7 @@ ScrapePoolContent.displayName = 'ScrapePoolContent';
|
|||
|
||||
const ScrapePoolListWithStatusIndicator = withStatusIndicator(ScrapePoolContent);
|
||||
|
||||
const ScrapePoolList: FC<{ filter: FilterData }> = ({ filter }) => {
|
||||
const ScrapePoolList: FC = () => {
|
||||
const pathPrefix = usePathPrefix();
|
||||
const { response, error, isLoading } = useFetch<ScrapePoolListProps>(`${pathPrefix}/${API_PATH}/targets?state=active`);
|
||||
const { status: responseStatus } = response;
|
||||
|
@ -39,7 +63,6 @@ const ScrapePoolList: FC<{ filter: FilterData }> = ({ filter }) => {
|
|||
return (
|
||||
<ScrapePoolListWithStatusIndicator
|
||||
{...response.data}
|
||||
filter={filter}
|
||||
error={badResponse ? new Error(responseStatus) : error}
|
||||
isLoading={isLoading}
|
||||
componentTitle="Targets information"
|
||||
|
|
|
@ -6,11 +6,14 @@ import { Button, Collapse, Table, Badge } from 'reactstrap';
|
|||
import { Target, getColor } from './target';
|
||||
import EndpointLink from './EndpointLink';
|
||||
import TargetLabels from './TargetLabels';
|
||||
import sinon from 'sinon';
|
||||
|
||||
describe('ScrapePoolPanel', () => {
|
||||
const defaultProps = {
|
||||
scrapePool: 'blackbox',
|
||||
targetGroup: targetGroups.blackbox,
|
||||
expanded: true,
|
||||
toggleExpanded: sinon.spy(),
|
||||
};
|
||||
const scrapePoolPanel = shallow(<ScrapePoolPanel {...defaultProps} />);
|
||||
|
||||
|
@ -31,6 +34,7 @@ describe('ScrapePoolPanel', () => {
|
|||
|
||||
it('renders an anchor with up count and normal color if upCount == targetsCount', () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
scrapePool: 'prometheus',
|
||||
targetGroup: targetGroups.prometheus,
|
||||
};
|
||||
|
@ -45,8 +49,10 @@ describe('ScrapePoolPanel', () => {
|
|||
|
||||
it('renders a show more btn if collapsed', () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
scrapePool: 'prometheus',
|
||||
targetGroup: targetGroups.prometheus,
|
||||
toggleExpanded: sinon.spy(),
|
||||
};
|
||||
const div = document.createElement('div');
|
||||
div.id = `series-labels-prometheus-0`;
|
||||
|
@ -55,8 +61,7 @@ describe('ScrapePoolPanel', () => {
|
|||
|
||||
const btn = scrapePoolPanel.find(Button);
|
||||
btn.simulate('click');
|
||||
const collapse = scrapePoolPanel.find(Collapse);
|
||||
expect(collapse.prop('isOpen')).toBe(false);
|
||||
expect(props.toggleExpanded.calledOnce).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -6,19 +6,19 @@ import { Target } from './target';
|
|||
import EndpointLink from './EndpointLink';
|
||||
import TargetLabels from './TargetLabels';
|
||||
import { now } from 'moment';
|
||||
import { useLocalStorage } from '../../hooks/useLocalStorage';
|
||||
import { ToggleMoreLess } from '../../components/ToggleMoreLess';
|
||||
import { formatRelative, humanizeDuration } from '../../utils';
|
||||
|
||||
interface PanelProps {
|
||||
scrapePool: string;
|
||||
targetGroup: ScrapePool;
|
||||
expanded: boolean;
|
||||
toggleExpanded: () => void;
|
||||
}
|
||||
|
||||
export const columns = ['Endpoint', 'State', 'Labels', 'Last Scrape', 'Scrape Duration', 'Error'];
|
||||
|
||||
const ScrapePoolPanel: FC<PanelProps> = ({ scrapePool, targetGroup }) => {
|
||||
const [{ expanded }, setOptions] = useLocalStorage(`targets-${scrapePool}-expanded`, { expanded: true });
|
||||
const ScrapePoolPanel: FC<PanelProps> = ({ scrapePool, targetGroup, expanded, toggleExpanded }) => {
|
||||
const modifier = targetGroup.upCount < targetGroup.targets.length ? 'danger' : 'normal';
|
||||
const id = `pool-${scrapePool}`;
|
||||
const anchorProps = {
|
||||
|
@ -28,7 +28,7 @@ const ScrapePoolPanel: FC<PanelProps> = ({ scrapePool, targetGroup }) => {
|
|||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<ToggleMoreLess event={(): void => setOptions({ expanded: !expanded })} showMore={expanded}>
|
||||
<ToggleMoreLess event={toggleExpanded} showMore={expanded}>
|
||||
<a className={styles[modifier]} {...anchorProps}>
|
||||
{`${scrapePool} (${targetGroup.upCount}/${targetGroup.targets.length} up)`}
|
||||
</a>
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import Targets from './Targets';
|
||||
import Filter from './Filter';
|
||||
import ScrapePoolList from './ScrapePoolList';
|
||||
|
||||
describe('Targets', () => {
|
||||
|
@ -19,14 +18,8 @@ describe('Targets', () => {
|
|||
expect(h2).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
it('renders a filter', () => {
|
||||
const filter = targets.find(Filter);
|
||||
expect(filter).toHaveLength(1);
|
||||
expect(filter.prop('filter')).toEqual({ showHealthy: true, showUnhealthy: true });
|
||||
});
|
||||
it('renders a scrape pool list', () => {
|
||||
const scrapePoolList = targets.find(ScrapePoolList);
|
||||
expect(scrapePoolList).toHaveLength(1);
|
||||
expect(scrapePoolList.prop('filter')).toEqual({ showHealthy: true, showUnhealthy: true });
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,22 +1,12 @@
|
|||
import React, { FC } from 'react';
|
||||
import { RouteComponentProps } from '@reach/router';
|
||||
import Filter from './Filter';
|
||||
import ScrapePoolList from './ScrapePoolList';
|
||||
import { useLocalStorage } from '../../hooks/useLocalStorage';
|
||||
import { usePathPrefix } from '../../contexts/PathPrefixContext';
|
||||
import { API_PATH } from '../../constants/constants';
|
||||
|
||||
const Targets: FC<RouteComponentProps> = () => {
|
||||
const pathPrefix = usePathPrefix();
|
||||
const [filter, setFilter] = useLocalStorage('targets-page-filter', { showHealthy: true, showUnhealthy: true });
|
||||
const filterProps = { filter, setFilter };
|
||||
const scrapePoolListProps = { filter, pathPrefix, API_PATH };
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Targets</h2>
|
||||
<Filter {...filterProps} />
|
||||
<ScrapePoolList {...scrapePoolListProps} />
|
||||
<ScrapePoolList />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue