* API client to get status label summary

* Handle status label counts in state

* Display status counts in hosts side panel
This commit is contained in:
Mike Stone 2017-01-16 15:59:01 -05:00 коммит произвёл GitHub
Родитель 066ec298b5
Коммит 630ba45448
15 изменённых файлов: 303 добавлений и 30 удалений

Просмотреть файл

@ -6,6 +6,7 @@ import InputField from 'components/forms/fields/InputField';
import labelInterface from 'interfaces/label';
import PanelGroup from 'components/side_panels/HostSidePanel/PanelGroup';
import SecondarySidePanelContainer from 'components/side_panels/SecondarySidePanelContainer';
import statusLabelsInterface from 'interfaces/status_labels';
const baseClass = 'host-side-panel';
@ -15,6 +16,7 @@ class HostSidePanel extends Component {
onAddLabelClick: PropTypes.func,
onLabelClick: PropTypes.func,
selectedLabel: labelInterface,
statusLabels: statusLabelsInterface,
};
constructor (props) {
@ -31,7 +33,7 @@ class HostSidePanel extends Component {
}
render () {
const { labels, onAddLabelClick, onLabelClick, selectedLabel } = this.props;
const { labels, onAddLabelClick, onLabelClick, selectedLabel, statusLabels } = this.props;
const { labelFilter } = this.state;
const { onFilterLabels } = this;
const allHostLabels = filter(labels, { type: 'all' });
@ -56,6 +58,7 @@ class HostSidePanel extends Component {
<PanelGroup
groupItems={hostStatusLabels}
onLabelClick={onLabelClick}
statusLabels={statusLabels}
selectedLabel={selectedLabel}
type="status"
/>

Просмотреть файл

@ -2,13 +2,15 @@ import React, { Component, PropTypes } from 'react';
import { isEqual, noop } from 'lodash';
import labelInterface from 'interfaces/label';
import PanelGroupItem from '../PanelGroupItem';
import PanelGroupItem from 'components/side_panels/HostSidePanel/PanelGroupItem';
import statusLabelsInterface from 'interfaces/status_labels';
class PanelGroup extends Component {
static propTypes = {
groupItems: PropTypes.arrayOf(labelInterface),
onLabelClick: PropTypes.func,
selectedLabel: labelInterface,
statusLabels: statusLabelsInterface,
type: PropTypes.string,
};
@ -20,6 +22,7 @@ class PanelGroup extends Component {
const {
onLabelClick,
selectedLabel,
statusLabels,
type,
} = this.props;
const selected = isEqual(selectedLabel, item);
@ -30,6 +33,7 @@ class PanelGroup extends Component {
item={item}
key={item.display_text}
onLabelClick={onLabelClick(item)}
statusLabels={statusLabels}
type={type}
/>
);

Просмотреть файл

@ -4,6 +4,7 @@ import classnames from 'classnames';
import Icon from 'components/icons/Icon';
import iconClassForLabel from 'utilities/icon_class_for_label';
import PlatformIcon from 'components/icons/PlatformIcon';
import statusLabelsInterface from 'interfaces/status_labels';
const baseClass = 'panel-group-item';
@ -17,9 +18,24 @@ class PanelGroupItem extends Component {
}).isRequired,
onLabelClick: PropTypes.func,
isSelected: PropTypes.bool,
statusLabels: statusLabelsInterface,
type: PropTypes.string,
};
displayCount = () => {
const { item, statusLabels, type } = this.props;
if (type !== 'status') {
return item.count;
}
if (statusLabels.loading_counts) {
return '';
}
return statusLabels[`${item.id}_count`];
}
renderIcon = () => {
const { item, type } = this.props;
@ -42,10 +58,9 @@ class PanelGroupItem extends Component {
}
render () {
const { renderDescription, renderIcon } = this;
const { displayCount, renderDescription, renderIcon } = this;
const { item, onLabelClick, isSelected } = this.props;
const {
count,
display_text: displayText,
type,
} = item;
@ -68,7 +83,7 @@ class PanelGroupItem extends Component {
{displayText}
{renderDescription()}
</span>
<span className={`${baseClass}__count`}>{count}</span>
<span className={`${baseClass}__count`}>{displayCount()}</span>
</div>
</button>
);

Просмотреть файл

@ -10,13 +10,35 @@ describe('PanelGroupItem - component', () => {
display_text: 'All Hosts',
type: 'all',
};
const validStatusGroupItem = {
count: 111,
display_text: 'Online Hosts',
id: 'online',
type: 'status',
};
const statusLabels = {
online_count: 20,
loading_counts: false,
};
const loadingStatusLabels = {
online_count: 20,
loading_counts: true,
};
const labelComponent = mount(
<PanelGroupItem item={validPanelGroupItem} />
<PanelGroupItem item={validPanelGroupItem} statusLabels={statusLabels} />
);
const platformComponent = mount(
<PanelGroupItem item={validPanelGroupItem} type="platform" />
<PanelGroupItem item={validPanelGroupItem} statusLabels={statusLabels} type="platform" />
);
const statusLabelComponent = mount(
<PanelGroupItem item={validStatusGroupItem} statusLabels={statusLabels} type="status" />
);
const loadingStatusLabelComponent = mount(
<PanelGroupItem item={validStatusGroupItem} statusLabels={loadingStatusLabels} type="status" />
);
it('renders the appropriate icon', () => {
@ -32,5 +54,9 @@ describe('PanelGroupItem - component', () => {
it('renders the item count', () => {
expect(labelComponent.text()).toContain(validPanelGroupItem.count);
expect(statusLabelComponent.text()).toNotContain(validStatusGroupItem.count);
expect(statusLabelComponent.text()).toContain(statusLabels.online_count);
expect(loadingStatusLabelComponent.text()).toNotContain(statusLabels.online_count);
expect(loadingStatusLabelComponent.text()).toNotContain(validPanelGroupItem.count);
});
});

Просмотреть файл

@ -2,7 +2,7 @@ import { PropTypes } from 'react';
export default PropTypes.shape({
hosts_count: PropTypes.number,
id: PropTypes.number,
id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
title: PropTypes.string,
type: PropTypes.string,
});

Просмотреть файл

@ -0,0 +1,8 @@
import { PropTypes } from 'react';
export default PropTypes.shape({
loading_counts: PropTypes.bool,
online_count: PropTypes.number,
offline_count: PropTypes.number,
mia_count: PropTypes.number,
});

Просмотреть файл

@ -20,6 +20,7 @@ export default {
return `/v1/kolide/packs/${pack.id}/scheduled`;
},
SETUP: '/v1/setup',
STATUS_LABEL_COUNTS: '/v1/kolide/host_summary',
TARGETS: '/v1/kolide/targets',
USERS: '/v1/kolide/users',
};

Просмотреть файл

@ -32,6 +32,14 @@ class Kolide extends Base {
},
}
statusLabels = {
getCounts: () => {
const { STATUS_LABEL_COUNTS } = endpoints;
return this.authenticatedGet(this.endpoint(STATUS_LABEL_COUNTS));
},
}
createLabel = ({ description, name, query }) => {
const { LABELS } = endpoints;
@ -243,9 +251,9 @@ class Kolide extends Base {
};
});
const stubbedLabels = [
{ id: 40, display_text: 'ONLINE', type: 'status', count: 20 },
{ id: 50, display_text: 'OFFLINE', type: 'status', count: 2 },
{ id: 55, display_text: 'MIA', description: '(offline > 30 days)', type: 'status', count: 3 },
{ id: 'online', display_text: 'ONLINE', slug: 'online', type: 'status', count: 0 },
{ id: 'offline', display_text: 'OFFLINE', slug: 'offline', type: 'status', count: 0 },
{ id: 'mia', display_text: 'MIA', description: '(offline > 30 days)', slug: 'mia', type: 'status', count: 0 },
];
return labels.concat(stubbedLabels);

Просмотреть файл

@ -34,6 +34,7 @@ const {
validRevokeInviteRequest,
validRunQueryRequest,
validSetupRequest,
validStatusLabelsGetCountsRequest,
validUpdateConfigOptionsRequest,
validUpdateConfigRequest,
validUpdatePackRequest,
@ -43,7 +44,10 @@ const {
} = mocks;
describe('Kolide - API client', () => {
afterEach(() => { nock.cleanAll(); });
afterEach(() => {
nock.cleanAll();
Kolide.setBearerToken(null);
});
describe('defaults', () => {
it('sets the base URL', () => {
@ -51,6 +55,23 @@ describe('Kolide - API client', () => {
});
});
describe('statusLabels', () => {
it('#getCounts', (done) => {
const bearerToken = 'valid-bearer-token';
const request = validStatusLabelsGetCountsRequest(bearerToken);
Kolide.setBearerToken(bearerToken);
Kolide.statusLabels.getCounts()
.then(() => {
expect(request.isDone()).toEqual(true);
done();
})
.catch(() => {
throw new Error('Endpoint not reached');
});
});
});
describe('#createLabel', () => {
it('calls the appropriate endpoint with the correct parameters', (done) => {
const bearerToken = 'valid-bearer-token';

Просмотреть файл

@ -5,6 +5,7 @@ import { push } from 'react-router-redux';
import { orderBy, sortBy } from 'lodash';
import entityGetter from 'redux/utilities/entityGetter';
import { getStatusLabelCounts, setDisplay } from 'redux/nodes/components/ManageHostsPage/actions';
import hostActions from 'redux/nodes/entities/hosts/actions';
import labelActions from 'redux/nodes/entities/labels/actions';
import labelInterface from 'interfaces/label';
@ -19,7 +20,7 @@ import QueryForm from 'components/forms/queries/QueryForm';
import QuerySidePanel from 'components/side_panels/QuerySidePanel';
import Rocker from 'components/buttons/Rocker';
import { selectOsqueryTable } from 'redux/nodes/components/QueryPages/actions';
import { setDisplay } from 'redux/nodes/components/ManageHostsPage/actions';
import statusLabelsInterface from 'interfaces/status_labels';
import iconClassForLabel from 'utilities/icon_class_for_label';
const NEW_LABEL_HASH = '#new_label';
@ -37,6 +38,7 @@ export class ManageHostsPage extends Component {
labels: PropTypes.arrayOf(labelInterface),
selectedLabel: labelInterface,
selectedOsqueryTable: osqueryTableInterface,
statusLabels: statusLabelsInterface,
};
static defaultProps = {
@ -52,19 +54,11 @@ export class ManageHostsPage extends Component {
}
componentWillMount () {
const {
dispatch,
hosts,
labels,
} = this.props;
const { dispatch } = this.props;
if (!hosts.length) {
dispatch(hostActions.loadAll());
}
if (!labels.length) {
dispatch(labelActions.loadAll());
}
dispatch(getStatusLabelCounts);
return false;
}
@ -281,6 +275,7 @@ export class ManageHostsPage extends Component {
labels,
selectedLabel,
selectedOsqueryTable,
statusLabels,
} = this.props;
const { onAddLabelClick, onLabelClick, onOsqueryTableSelect } = this;
@ -300,6 +295,7 @@ export class ManageHostsPage extends Component {
onAddLabelClick={onAddLabelClick}
onLabelClick={onLabelClick}
selectedLabel={selectedLabel}
statusLabels={statusLabels}
/>
);
}
@ -331,7 +327,7 @@ export class ManageHostsPage extends Component {
const mapStateToProps = (state, { location, params }) => {
const activeLabelSlug = params.active_label || 'all-hosts';
const { display } = state.components.ManageHostsPage;
const { display, status_labels: statusLabels } = state.components.ManageHostsPage;
const { entities: hosts } = entityGetter(state).get('hosts');
const labelEntities = entityGetter(state).get('labels');
const { entities: labels } = labelEntities;
@ -351,6 +347,7 @@ const mapStateToProps = (state, { location, params }) => {
labels,
selectedLabel,
selectedOsqueryTable,
statusLabels,
};
};

Просмотреть файл

@ -28,6 +28,7 @@ const mockStore = reduxMockStore({
ManageHostsPage: {
display: 'Grid',
selectedLabel: { id: 100, display_text: 'All Hosts', type: 'all', count: 22 },
status_labels: {},
},
QueryPages: {
selectedOsqueryTable: stubbedOsqueryTable,

Просмотреть файл

@ -1,4 +1,45 @@
import Kolide from 'kolide';
import { formatErrorResponse } from 'redux/nodes/entities/base/helpers';
// Action Types
export const GET_STATUS_LABEL_COUNTS_FAILURE = 'GET_STATUS_LABEL_COUNTS_FAILURE';
export const GET_STATUS_LABEL_COUNTS_SUCCESS = 'GET_STATUS_LABEL_COUNTS_SUCCESS';
export const LOAD_STATUS_LABEL_COUNTS = 'LOAD_STATUS_LABEL_COUNTS';
export const SET_DISPLAY = 'SET_DISPLAY';
// Actions
export const loadStatusLabelCounts = { type: LOAD_STATUS_LABEL_COUNTS };
export const getStatusLabelCountsFailure = (errors) => {
return {
type: GET_STATUS_LABEL_COUNTS_FAILURE,
payload: { errors },
};
};
export const getStatusLabelCountsSuccess = (statusLabelCounts) => {
return {
type: GET_STATUS_LABEL_COUNTS_SUCCESS,
payload: { status_labels: statusLabelCounts },
};
};
export const getStatusLabelCounts = (dispatch) => {
dispatch(loadStatusLabelCounts);
return Kolide.statusLabels.getCounts()
.then((counts) => {
dispatch(getStatusLabelCountsSuccess(counts));
return counts;
})
.catch((response) => {
const errorsObject = formatErrorResponse(response);
dispatch(getStatusLabelCountsFailure(errorsObject));
throw errorsObject;
});
};
export const setDisplay = (display) => {
return {
type: SET_DISPLAY,
@ -8,4 +49,4 @@ export const setDisplay = (display) => {
};
};
export default { setDisplay };
export default { getStatusLabelCounts, setDisplay };

Просмотреть файл

@ -1,11 +1,49 @@
import { SET_DISPLAY } from './actions';
import {
GET_STATUS_LABEL_COUNTS_FAILURE,
GET_STATUS_LABEL_COUNTS_SUCCESS,
LOAD_STATUS_LABEL_COUNTS,
SET_DISPLAY,
} from './actions';
export const initialState = {
display: 'Grid',
status_labels: {
errors: {},
loading_counts: false,
online_count: 0,
offline_count: 0,
mia_count: 0,
},
};
export default (state = initialState, { type, payload }) => {
switch (type) {
case GET_STATUS_LABEL_COUNTS_FAILURE:
return {
...state,
status_labels: {
...state.status_labels,
errors: payload.errors,
loading_counts: false,
},
};
case GET_STATUS_LABEL_COUNTS_SUCCESS:
return {
...state,
status_labels: {
...payload.status_labels,
errors: {},
loading_counts: false,
},
};
case LOAD_STATUS_LABEL_COUNTS:
return {
...state,
status_labels: {
...state.status_labels,
loading_counts: true,
},
};
case SET_DISPLAY:
return {
...state,

Просмотреть файл

@ -1,9 +1,19 @@
import expect from 'expect';
import expect, { spyOn, restoreSpies } from 'expect';
import { setDisplay } from './actions';
import Kolide from 'kolide';
import { reduxMockStore } from 'test/helpers';
import {
getStatusLabelCounts,
getStatusLabelCountsFailure,
getStatusLabelCountsSuccess,
loadStatusLabelCounts,
setDisplay,
} from './actions';
import reducer, { initialState } from './reducer';
describe('ManageHostsPage - reducer', () => {
afterEach(restoreSpies);
it('sets the initial state', () => {
expect(reducer(undefined, { type: 'SOME_ACTION' })).toEqual(initialState);
});
@ -16,4 +26,93 @@ describe('ManageHostsPage - reducer', () => {
});
});
});
describe('#getStatusLabelCounts', () => {
it('sets the loading boolean', () => {
expect(reducer(initialState, loadStatusLabelCounts)).toEqual({
...initialState,
status_labels: {
...initialState.status_labels,
loading_counts: true,
},
});
});
it('dispatches the correct actions when successful', (done) => {
const statusLabelCounts = { online_count: 23, offline_count: 100, mia_count: 2 };
const store = { components: { ManageHostsPage: initialState } };
const mockStore = reduxMockStore(store);
const expectedActions = [
{ type: 'LOAD_STATUS_LABEL_COUNTS' },
{
type: 'GET_STATUS_LABEL_COUNTS_SUCCESS',
payload: { status_labels: statusLabelCounts },
},
];
spyOn(Kolide.statusLabels, 'getCounts')
.andReturn(Promise.resolve(statusLabelCounts));
mockStore.dispatch(getStatusLabelCounts)
.then(() => {
expect(mockStore.getActions()).toEqual(expectedActions);
done();
});
});
it('dispatches the correct actions when unsuccessful', (done) => {
const store = { components: { ManageHostsPage: initialState } };
const mockStore = reduxMockStore(store);
const errors = [{ name: 'error_name', reason: 'error reason' }];
const errorObject = { message: { message: 'oops', errors } };
const expectedActions = [
{ type: 'LOAD_STATUS_LABEL_COUNTS' },
{
type: 'GET_STATUS_LABEL_COUNTS_FAILURE',
payload: { errors: { error_name: 'error reason' } },
},
];
spyOn(Kolide.statusLabels, 'getCounts')
.andReturn(Promise.reject(errorObject));
mockStore.dispatch(getStatusLabelCounts)
.then(() => {
throw new Error('Promise should have failed');
})
.catch(() => {
expect(mockStore.getActions()).toEqual(expectedActions);
done();
});
});
it('adds the label counts to state when successful', () => {
const statusLabelCounts = { online_count: 23, offline_count: 100, mia_count: 2 };
const successAction = getStatusLabelCountsSuccess(statusLabelCounts);
expect(reducer(initialState, successAction)).toEqual({
...initialState,
status_labels: {
...statusLabelCounts,
errors: {},
loading_counts: false,
},
});
});
it('adds errors to state when unsuccessful', () => {
const errors = { error_name: 'error reason' };
const failureAction = getStatusLabelCountsFailure(errors);
expect(reducer(initialState, failureAction)).toEqual({
...initialState,
status_labels: {
...initialState.status_labels,
errors,
loading_counts: false,
},
});
});
});
});

Просмотреть файл

@ -333,6 +333,16 @@ export const validSetupRequest = (formData) => {
.reply(200, {});
};
export const validStatusLabelsGetCountsRequest = (bearerToken) => {
return nock('http://localhost:8080', {
reqHeaders: {
Authorization: `Bearer ${bearerToken}`,
},
})
.get('/api/v1/kolide/host_summary')
.reply(200, { online_count: 100, offline_count: 23, mia_count: 2 });
};
export const validUpdateConfigRequest = (bearerToken, configData) => {
return nock('http://localhost:8080', {
reqHeaders: {
@ -409,6 +419,7 @@ export default {
validRevokeInviteRequest,
validRunQueryRequest,
validSetupRequest,
validStatusLabelsGetCountsRequest,
validUpdateConfigOptionsRequest,
validUpdateConfigRequest,
validUpdatePackRequest,