Add warning in query UI when Redis fails (#2086)

- Add warning message when Redis fails
- Disable query button when Redis fails
- Refactor SMTP warning banner into component for reuse

Closes #2073
This commit is contained in:
Zachary Wasserman 2019-08-13 09:42:58 -07:00 committed by GitHub
parent 363b6157c4
commit 1eccf9a874
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 176 additions and 22 deletions

View file

@ -1 +0,0 @@
export default from './SmtpWarning';

View file

@ -5,33 +5,37 @@ import classnames from 'classnames';
import Button from 'components/buttons/Button';
import Icon from 'components/icons/Icon';
const baseClass = 'smtp-warning';
const baseClass = 'warning-banner';
const SmtpWarning = ({ className, onDismiss, onResolve, shouldShowWarning }) => {
const WarningBanner = ({ className, message, labelText, shouldShowWarning, onDismiss, onResolve }) => {
if (!shouldShowWarning) {
return false;
}
const fullClassName = classnames(baseClass, className);
const label = labelText || 'Warning!';
return (
<div className={fullClassName}>
<div className={`${baseClass}__icon-wrap`}>
<Icon name="warning-filled" />
<span className={`${baseClass}__label`}>Warning!</span>
<span className={`${baseClass}__label`}>{label}</span>
</div>
<span className={`${baseClass}__text`}>Email is not currently configured in Fleet. Many features rely on email to work.</span>
<span className={`${baseClass}__text`}>{message}</span>
{onDismiss && <Button onClick={onDismiss} variant="unstyled">Dismiss</Button>}
{onResolve && <Button onClick={onResolve} variant="unstyled">Resolve</Button>}
</div>
);
};
SmtpWarning.propTypes = {
WarningBanner.propTypes = {
className: PropTypes.string,
message: PropTypes.string.isRequired,
labelText: PropTypes.string,
onDismiss: PropTypes.func,
onResolve: PropTypes.func,
shouldShowWarning: PropTypes.bool.isRequired,
};
export default SmtpWarning;
export default WarningBanner;

View file

@ -0,0 +1,46 @@
import React from 'react';
import expect, { createSpy } from 'expect';
import { shallow } from 'enzyme';
import WarningBanner from 'components/WarningBanner/WarningBanner';
describe('WarningBanner - component', () => {
it('renders default banner', () => {
const props = { shouldShowWarning: true, message: 'message' };
const component = shallow(<WarningBanner {...props} />);
expect(component.length).toEqual(1);
expect(component.find('Icon').props().name).toEqual('warning-filled');
expect(component.find('.warning-banner__label').text()).toEqual('Warning!');
expect(component.find('.warning-banner__text').text()).toEqual('message');
});
it('renders custom label', () => {
const props = { shouldShowWarning: true, message: 'message', labelText: 'label' };
const component = shallow(<WarningBanner {...props} />);
expect(component.find('.warning-banner__label').text()).toEqual('label');
});
it('renders empty when disabled', () => {
const props = { shouldShowWarning: false, message: 'message' };
const component = shallow(<WarningBanner {...props} />);
expect(component.html()).toBe(null);
});
it('handles dismiss action', () => {
const spy = createSpy();
const props = { shouldShowWarning: true, message: 'message', onDismiss: spy };
const component = shallow(<WarningBanner {...props} />);
component.find('Button').simulate('click');
expect(spy).toHaveBeenCalled();
});
it('handles resolve action', () => {
const spy = createSpy();
const props = { shouldShowWarning: true, message: 'message', onResolve: spy };
const component = shallow(<WarningBanner {...props} />);
component.find('Button').simulate('click');
expect(spy).toHaveBeenCalled();
});
});

View file

@ -1,4 +1,4 @@
.smtp-warning {
.warning-banner {
display: flex;
justify-content: space-between;
align-items: flex-start;
@ -34,4 +34,3 @@
margin-left: 15px;
}
}

View file

@ -0,0 +1 @@
export default from './WarningBanner';

View file

@ -20,6 +20,7 @@ class QueryPageSelectTargets extends Component {
selectedTargets: PropTypes.arrayOf(targetInterface),
targetsCount: PropTypes.number,
queryTimerMilliseconds: PropTypes.number,
disableRun: PropTypes.bool,
};
render () {
@ -34,6 +35,7 @@ class QueryPageSelectTargets extends Component {
onStopQuery,
queryIsRunning,
queryTimerMilliseconds,
disableRun,
} = this.props;
return (
@ -44,6 +46,7 @@ class QueryPageSelectTargets extends Component {
onStopQuery={onStopQuery}
queryIsRunning={queryIsRunning}
queryTimerMilliseconds={queryTimerMilliseconds}
disableRun={disableRun}
/>
<SelectTargetsDropdown
error={error}

View file

@ -9,7 +9,7 @@ import Timer from 'components/loaders/Timer';
const baseClass = 'query-progress-details';
const QueryProgressDetails = ({ campaign, className, onRunQuery, onStopQuery, queryIsRunning, queryTimerMilliseconds }) => {
const QueryProgressDetails = ({ campaign, className, onRunQuery, onStopQuery, queryIsRunning, queryTimerMilliseconds, disableRun }) => {
const { hosts_count: hostsCount } = campaign;
const totalHostsCount = get(campaign, ['totals', 'count'], 0);
const totalRowsCount = get(campaign, ['query_results', 'length'], 0);
@ -20,6 +20,7 @@ const QueryProgressDetails = ({ campaign, className, onRunQuery, onStopQuery, qu
className={`${baseClass}__run-btn`}
onClick={onRunQuery}
variant="success"
disabled={disableRun}
>
Run
</Button>
@ -75,6 +76,7 @@ QueryProgressDetails.propTypes = {
onStopQuery: PropTypes.func.isRequired,
queryIsRunning: PropTypes.bool,
queryTimerMilliseconds: PropTypes.number,
disableRun: PropTypes.bool,
};
export default QueryProgressDetails;

View file

@ -34,4 +34,5 @@ export default {
return `/v1/kolide/users/${id}/admin`;
},
SSO: '/v1/kolide/sso',
STATUS_RESULT_STORE: '/v1/kolide/status/result_store',
};

View file

@ -13,6 +13,7 @@ import statusLabelMethods from 'kolide/entities/status_labels';
import targetMethods from 'kolide/entities/targets';
import userMethods from 'kolide/entities/users';
import websocketMethods from 'kolide/websockets';
import statusMethods from 'kolide/status';
const DEFAULT_BODY = JSON.stringify({});
@ -33,6 +34,7 @@ class Kolide extends Base {
this.targets = targetMethods(this);
this.users = userMethods(this);
this.websockets = websocketMethods(this);
this.status = statusMethods(this);
}
authenticatedDelete (endpoint, overrideHeaders = {}) {

12
frontend/kolide/status.js Normal file
View file

@ -0,0 +1,12 @@
import endpoints from 'kolide/endpoints';
export default (client) => {
return {
result_store: () => {
const { STATUS_RESULT_STORE } = endpoints;
const endpoint = client.baseURL + STATUS_RESULT_STORE;
return client.authenticatedGet(endpoint);
},
};
};

View file

@ -7,7 +7,7 @@ import AppConfigForm from 'components/forms/admin/AppConfigForm';
import configInterface from 'interfaces/config';
import deepDifference from 'utilities/deep_difference';
import { renderFlash } from 'redux/nodes/notifications/actions';
import SmtpWarning from 'components/SmtpWarning';
import WarningBanner from 'components/WarningBanner';
import { updateConfig } from 'redux/nodes/app/actions';
export const baseClass = 'app-settings';
@ -68,7 +68,8 @@ class AppSettingsPage extends Component {
return (
<div className={`${baseClass} body-wrap`}>
<h1>App Settings</h1>
<SmtpWarning
<WarningBanner
message="Email is not currently configured in Fleet. Many features rely on email to work."
onDismiss={onDismissSmtpWarning}
shouldShowWarning={shouldShowWarning}
/>

View file

@ -28,7 +28,7 @@ describe('AppSettingsPage - component', () => {
connectedComponent(AppSettingsPage, { mockStore })
).find('AppSettingsPage');
const smtpWarning = page.find('SmtpWarning');
const smtpWarning = page.find('WarningBanner');
expect(smtpWarning.length).toEqual(1);
expect(smtpWarning.find('Icon').length).toEqual(1);
@ -41,12 +41,12 @@ describe('AppSettingsPage - component', () => {
connectedComponent(AppSettingsPage, { mockStore })
);
const smtpWarning = page.find('SmtpWarning');
const smtpWarning = page.find('WarningBanner');
const dismissButton = smtpWarning.find('Button').first();
dismissButton.simulate('click');
expect(page.find('SmtpWarning').html()).toNotExist();
expect(page.find('WarningBanner').html()).toNotExist();
});
it('does not render a warning if SMTP has been configured', () => {
@ -55,6 +55,6 @@ describe('AppSettingsPage - component', () => {
connectedComponent(AppSettingsPage, { mockStore })
).find('AppSettingsPage');
expect(page.find('SmtpWarning').html()).toNotExist();
expect(page.find('WarningBanner').html()).toNotExist();
});
});

View file

@ -14,7 +14,7 @@ import InviteUserForm from 'components/forms/InviteUserForm';
import Modal from 'components/modals/Modal';
import paths from 'router/paths';
import { renderFlash } from 'redux/nodes/notifications/actions';
import SmtpWarning from 'components/SmtpWarning';
import WarningBanner from 'components/WarningBanner';
import { updateUser } from 'redux/nodes/auth/actions';
import userActions from 'redux/nodes/entities/users/actions';
import UserBlock from 'components/UserBlock';
@ -249,7 +249,8 @@ export class UserManagementPage extends Component {
return (
<div className={`${baseClass}__smtp-warning-wrapper`}>
<SmtpWarning
<WarningBanner
message="Email is not currently configured in Fleet. User management features require email."
onResolve={goToAppConfigPage}
shouldShowWarning={!config.configured}
/>

View file

@ -136,8 +136,8 @@ describe('UserManagementPage - component', () => {
mockStore: configuredMockStore,
}));
expect(notConfiguredPage.find('SmtpWarning').html()).toExist();
expect(configuredPage.find('SmtpWarning').html()).toNotExist();
expect(notConfiguredPage.find('WarningBanner').html()).toExist();
expect(configuredPage.find('WarningBanner').html()).toNotExist();
});
});
@ -146,7 +146,7 @@ describe('UserManagementPage - component', () => {
const mockStore = reduxMockStore(notConfiguredStore);
const page = mount(connectedComponent(ConnectedUserManagementPage, { mockStore }));
const smtpWarning = page.find('SmtpWarning');
const smtpWarning = page.find('WarningBanner');
smtpWarning.find('Button').simulate('click');

View file

@ -17,6 +17,7 @@ import { formatSelectedTargetsForApi } from 'kolide/helpers';
import helpers from 'pages/queries/QueryPage/helpers';
import hostActions from 'redux/nodes/entities/hosts/actions';
import hostInterface from 'interfaces/host';
import WarningBanner from 'components/WarningBanner';
import QueryForm from 'components/forms/queries/QueryForm';
import osqueryTableInterface from 'interfaces/osquery_table';
import queryActions from 'redux/nodes/entities/queries/actions';
@ -91,6 +92,10 @@ export class QueryPage extends Component {
dispatch(hostActions.loadAll());
}
Kolide.status.result_store().catch((response) => {
this.setState({ resultStoreError: response.message.errors[0].reason });
});
helpers.selectHosts(dispatch, {
hosts: selectedHosts,
selectedTargets,
@ -445,6 +450,20 @@ export class QueryPage extends Component {
return false;
}
renderResultStoreWarning = () => {
const { resultStoreError } = this.state;
if (!resultStoreError) {
return false;
}
const message = `Live query disabled due to Redis error: ${resultStoreError}`;
return (
<WarningBanner labelText="Warning!" className={`${baseClass}__warning`} message={message} shouldShowWarning />
);
}
renderResultsTable = () => {
const {
campaign,
@ -485,7 +504,7 @@ export class QueryPage extends Component {
renderTargetsInput = () => {
const { onFetchTargets, onRunQuery, onStopQuery, onTargetSelect } = this;
const { campaign, queryIsRunning, targetsCount, targetsError, runQueryMilliseconds } = this.state;
const { campaign, queryIsRunning, targetsCount, targetsError, runQueryMilliseconds, resultStoreError } = this.state;
const { selectedTargets } = this.props;
return (
@ -500,6 +519,7 @@ export class QueryPage extends Component {
selectedTargets={selectedTargets}
targetsCount={targetsCount}
queryTimerMilliseconds={runQueryMilliseconds}
disableRun={resultStoreError !== undefined}
/>
);
}
@ -515,6 +535,7 @@ export class QueryPage extends Component {
onUpdateQuery,
renderResultsTable,
renderTargetsInput,
renderResultStoreWarning,
} = this;
const { queryIsRunning } = this.state;
const {
@ -547,6 +568,7 @@ export class QueryPage extends Component {
title={title}
/>
</div>
{renderResultStoreWarning()}
{renderTargetsInput()}
{renderResultsTable()}
</div>

View file

@ -12,4 +12,8 @@
position: relative;
min-height: 400px;
}
&__warning {
margin: 0px;
}
}

View file

@ -16,4 +16,8 @@ type QueryResultStore interface {
// query results. Channel values should be either
// DistributedQueryResult or error
ReadChannel(ctx context.Context, query DistributedQueryCampaign) (<-chan interface{}, error)
// HealthCheck returns nil if the store is functioning properly, or an
// error describing the problem.
HealthCheck() error
}

View file

@ -17,4 +17,5 @@ type Service interface {
ScheduledQueryService
OptionService
FileIntegrityMonitoringService
StatusService
}

9
server/kolide/status.go Normal file
View file

@ -0,0 +1,9 @@
package kolide
import "context"
type StatusService interface {
// StatusResultStore returns nil if the result store is functioning
// correctly, or an error indicating the problem.
StatusResultStore(ctx context.Context) error
}

View file

@ -60,3 +60,7 @@ func (im *inmemQueryResults) ReadChannel(ctx context.Context, query kolide.Distr
}()
return channel, nil
}
func (im *inmemQueryResults) HealthCheck() error {
return nil
}

View file

@ -0,0 +1,24 @@
package service
import (
"context"
"github.com/go-kit/kit/endpoint"
"github.com/kolide/fleet/server/kolide"
)
type statusResultStoreResponse struct {
Err error `json:"error,omitempty"`
}
func (m statusResultStoreResponse) error() error { return m.Err }
func makeStatusResultStoreEndpoint(svc kolide.Service) endpoint.Endpoint {
return func(ctx context.Context, req interface{}) (interface{}, error) {
var resp statusResultStoreResponse
if err := svc.StatusResultStore(ctx); err != nil {
resp.Err = err
}
return resp, nil
}
}

View file

@ -96,6 +96,7 @@ type KolideEndpoints struct {
SSOSettings endpoint.Endpoint
GetFIM endpoint.Endpoint
ModifyFIM endpoint.Endpoint
StatusResultStore endpoint.Endpoint
}
// MakeKolideServerEndpoints creates the Kolide API endpoints.
@ -188,6 +189,9 @@ func MakeKolideServerEndpoints(svc kolide.Service, jwtKey string) KolideEndpoint
GetFIM: authenticatedUser(jwtKey, svc, makeGetFIMEndpoint(svc)),
ModifyFIM: authenticatedUser(jwtKey, svc, makeModifyFIMEndpoint(svc)),
// Authenticated status endpoints
StatusResultStore: authenticatedUser(jwtKey, svc, makeStatusResultStoreEndpoint(svc)),
// Osquery endpoints
EnrollAgent: makeEnrollAgentEndpoint(svc),
GetClientConfig: authenticatedHost(svc, makeGetClientConfigEndpoint(svc)),
@ -279,6 +283,7 @@ type kolideHandlers struct {
SettingsSSO http.Handler
ModifyFIM http.Handler
GetFIM http.Handler
StatusResultStore http.Handler
}
func makeKolideKitHandlers(e KolideEndpoints, opts []kithttp.ServerOption) *kolideHandlers {
@ -367,6 +372,7 @@ func makeKolideKitHandlers(e KolideEndpoints, opts []kithttp.ServerOption) *koli
SettingsSSO: newServer(e.SSOSettings, decodeNoParamsRequest),
ModifyFIM: newServer(e.ModifyFIM, decodeModifyFIMRequest),
GetFIM: newServer(e.GetFIM, decodeNoParamsRequest),
StatusResultStore: newServer(e.StatusResultStore, decodeNoParamsRequest),
}
}
@ -496,6 +502,8 @@ func attachKolideAPIRoutes(r *mux.Router, h *kolideHandlers) {
r.Handle("/api/v1/kolide/targets", h.SearchTargets).Methods("POST").Name("search_targets")
r.Handle("/api/v1/kolide/status/result_store", h.StatusResultStore).Methods("GET").Name("status_result_store")
r.Handle("/api/v1/osquery/enroll", h.EnrollAgent).Methods("POST").Name("enroll_agent")
r.Handle("/api/v1/osquery/config", h.GetClientConfig).Methods("POST").Name("get_client_config")
r.Handle("/api/v1/osquery/distributed/read", h.GetDistributedQueries).Methods("POST").Name("get_distributed_queries")

View file

@ -0,0 +1,7 @@
package service
import "context"
func (svc service) StatusResultStore(ctx context.Context) error {
return svc.resultStore.HealthCheck()
}