Merge pull request #46 from prymitive/pause-fetch

feat(ui): pause fetching when alert/group menu is open
This commit is contained in:
Łukasz Mierzwa
2018-09-20 15:00:27 +01:00
committed by GitHub
13 changed files with 316 additions and 124 deletions

View File

@@ -0,0 +1,29 @@
import { Component } from "react";
import PropTypes from "prop-types";
import { inject } from "mobx-react";
const FetchPauser = inject("alertStore")(
class FetchPauser extends Component {
static propTypes = {
children: PropTypes.any,
alertStore: PropTypes.object.isRequired
};
componentDidMount() {
const { alertStore } = this.props;
alertStore.status.pause();
}
componentWillUnmount() {
const { alertStore } = this.props;
alertStore.status.resume();
}
render() {
return this.props.children;
}
}
);
export { FetchPauser };

View File

@@ -0,0 +1,37 @@
import React from "react";
import { Provider } from "mobx-react";
import { mount } from "enzyme";
import { AlertStore } from "Stores/AlertStore";
import { FetchPauser } from ".";
let alertStore;
beforeEach(() => {
alertStore = new AlertStore([]);
});
const MountedFetchPauser = () => {
return mount(
<Provider alertStore={alertStore}>
<FetchPauser>
<div />
</FetchPauser>
</Provider>
);
};
describe("<FetchPauser />", () => {
it("mounting FetchPauser pauses alertStore", () => {
MountedFetchPauser();
expect(alertStore.status.paused).toBe(true);
});
it("unmounting FetchPauser resumes alertStore", () => {
const tree = MountedFetchPauser();
tree.unmount();
expect(alertStore.status.paused).toBe(false);
});
});

View File

@@ -43,7 +43,7 @@ const Fetcher = observer(
status === AlertStoreStatuses.Fetching.toString() ||
status === AlertStoreStatuses.Processing.toString();
if (pastDeadline && !updateInProgress) {
if (pastDeadline && !updateInProgress && !alertStore.status.paused) {
this.lastTick.update();
alertStore.fetchWithThrottle();
}
@@ -61,8 +61,10 @@ const Fetcher = observer(
componentDidUpdate() {
const { alertStore } = this.props;
this.lastTick.update();
alertStore.fetchWithThrottle();
if (!alertStore.status.paused) {
this.lastTick.update();
alertStore.fetchWithThrottle();
}
}
componentWillUnmount() {

View File

@@ -147,4 +147,34 @@ describe("<Fetcher />", () => {
instance.componentWillUnmount();
expect(instance.timer).toBeNull();
});
it("doesn't fetch on mount when paused", () => {
alertStore.status.pause();
MountedFetcher();
expect(fetchSpy).toHaveBeenCalledTimes(0);
});
it("doesn't fetch on update when paused", () => {
alertStore.status.pause();
const tree = MountedFetcher();
tree.instance().componentDidUpdate();
expect(fetchSpy).toHaveBeenCalledTimes(0);
});
it("fetches on update when resumed", () => {
alertStore.status.pause();
const tree = MountedFetcher();
alertStore.status.resume();
tree.instance().componentDidUpdate();
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it("fetches on resume", () => {
alertStore.status.pause();
MountedFetcher();
alertStore.status.resume();
advanceBy(2 * 1000);
jest.runOnlyPendingTimers();
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
});

View File

@@ -15,6 +15,8 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCaretDown } from "@fortawesome/free-solid-svg-icons/faCaretDown";
import { faBellSlash } from "@fortawesome/free-solid-svg-icons/faBellSlash";
import { FetchPauser } from "Components/FetchPauser";
const onSilenceClick = (silenceFormStore, group, alert) => {
silenceFormStore.data.resetProgress();
silenceFormStore.data.fillMatchersFromGroup(group, [alert]);
@@ -32,33 +34,35 @@ const MenuContent = onClickOutside(
silenceFormStore
}) => {
return (
<div
className="dropdown-menu d-block"
ref={popperRef}
style={popperStyle}
data-placement={popperPlacement}
>
<h6 className="dropdown-header">Alert source links:</h6>
{alert.alertmanager.map(am => (
<a
key={am.name}
className="dropdown-item"
href={am.source}
target="_blank"
rel="noopener noreferrer"
onClick={afterClick}
>
{am.name}
</a>
))}
<div className="dropdown-divider" />
<FetchPauser>
<div
className="dropdown-item cursor-pointer"
onClick={() => onSilenceClick(silenceFormStore, group, alert)}
className="dropdown-menu d-block"
ref={popperRef}
style={popperStyle}
data-placement={popperPlacement}
>
<FontAwesomeIcon icon={faBellSlash} /> Silence this alert
<h6 className="dropdown-header">Alert source links:</h6>
{alert.alertmanager.map(am => (
<a
key={am.name}
className="dropdown-item"
href={am.source}
target="_blank"
rel="noopener noreferrer"
onClick={afterClick}
>
{am.name}
</a>
))}
<div className="dropdown-divider" />
<div
className="dropdown-item cursor-pointer"
onClick={() => onSilenceClick(silenceFormStore, group, alert)}
>
<FontAwesomeIcon icon={faBellSlash} /> Silence this alert
</div>
</div>
</div>
</FetchPauser>
);
}
);

View File

@@ -1,16 +1,21 @@
import React from "react";
import { Provider } from "mobx-react";
import { mount } from "enzyme";
import { MockAlertGroup, MockAlert } from "__mocks__/Alerts.js";
import { AlertStore } from "Stores/AlertStore";
import { SilenceFormStore } from "Stores/SilenceFormStore";
import { AlertMenu, MenuContent } from "./AlertMenu";
let alertStore;
let silenceFormStore;
let alert;
let group;
beforeEach(() => {
alertStore = new AlertStore([]);
silenceFormStore = new SilenceFormStore();
alert = MockAlert([], { foo: "bar" }, "active");
group = MockAlertGroup({ alertname: "Fake Alert" }, [alert], [], {});
@@ -20,12 +25,14 @@ const MockAfterClick = jest.fn();
const MountedAlertMenu = group => {
return mount(
<AlertMenu
group={group}
alert={alert}
silenceFormStore={silenceFormStore}
/>
);
<Provider alertStore={alertStore}>
<AlertMenu
group={group}
alert={alert}
silenceFormStore={silenceFormStore}
/>
</Provider>
).find("AlertMenu");
};
describe("<AlertMenu />", () => {
@@ -55,15 +62,17 @@ describe("<AlertMenu />", () => {
const MountedMenuContent = group => {
return mount(
<MenuContent
popperPlacement="top"
popperRef={null}
popperStyle={{}}
group={group}
alert={alert}
afterClick={MockAfterClick}
silenceFormStore={silenceFormStore}
/>
<Provider alertStore={alertStore}>
<MenuContent
popperPlacement="top"
popperRef={null}
popperStyle={{}}
group={group}
alert={alert}
afterClick={MockAfterClick}
silenceFormStore={silenceFormStore}
/>
</Provider>
);
};

View File

@@ -16,6 +16,7 @@ import { faBellSlash } from "@fortawesome/free-solid-svg-icons/faBellSlash";
import { FormatAPIFilterQuery } from "Stores/AlertStore";
import { QueryOperators, StaticLabels, FormatQuery } from "Common/Query";
import { FetchPauser } from "Components/FetchPauser";
const onSilenceClick = (silenceFormStore, group) => {
silenceFormStore.data.resetProgress();
@@ -43,28 +44,30 @@ const MenuContent = onClickOutside(
)}`;
return (
<div
className="dropdown-menu d-block"
ref={popperRef}
style={popperStyle}
data-placement={popperPlacement}
>
<FetchPauser>
<div
className="dropdown-item cursor-pointer"
onClick={() => {
copy(groupLink);
afterClick();
}}
className="dropdown-menu d-block"
ref={popperRef}
style={popperStyle}
data-placement={popperPlacement}
>
<FontAwesomeIcon icon={faShareSquare} /> Copy link to this group
<div
className="dropdown-item cursor-pointer"
onClick={() => {
copy(groupLink);
afterClick();
}}
>
<FontAwesomeIcon icon={faShareSquare} /> Copy link to this group
</div>
<div
className="dropdown-item cursor-pointer"
onClick={() => onSilenceClick(silenceFormStore, group)}
>
<FontAwesomeIcon icon={faBellSlash} /> Silence this group
</div>
</div>
<div
className="dropdown-item cursor-pointer"
onClick={() => onSilenceClick(silenceFormStore, group)}
>
<FontAwesomeIcon icon={faBellSlash} /> Silence this group
</div>
</div>
</FetchPauser>
);
}
);

View File

@@ -1,23 +1,32 @@
import React from "react";
import { Provider } from "mobx-react";
import { mount } from "enzyme";
import copy from "copy-to-clipboard";
import { MockAlertGroup } from "__mocks__/Alerts.js";
import { AlertStore } from "Stores/AlertStore";
import { SilenceFormStore } from "Stores/SilenceFormStore";
import { GroupMenu, MenuContent } from "./GroupMenu";
let alertStore;
let silenceFormStore;
beforeEach(() => {
alertStore = new AlertStore([]);
silenceFormStore = new SilenceFormStore();
});
const MockAfterClick = jest.fn();
const MountedGroupMenu = group => {
return mount(<GroupMenu group={group} silenceFormStore={silenceFormStore} />);
return mount(
<Provider alertStore={alertStore}>
<GroupMenu group={group} silenceFormStore={silenceFormStore} />
</Provider>
).find("GroupMenu");
};
describe("<GroupMenu />", () => {
@@ -51,14 +60,16 @@ describe("<GroupMenu />", () => {
const MountedMenuContent = group => {
return mount(
<MenuContent
popperPlacement="top"
popperRef={null}
popperStyle={{}}
group={group}
afterClick={MockAfterClick}
silenceFormStore={silenceFormStore}
/>
<Provider alertStore={alertStore}>
<MenuContent
popperPlacement="top"
popperRef={null}
popperStyle={{}}
group={group}
afterClick={MockAfterClick}
silenceFormStore={silenceFormStore}
/>
</Provider>
);
};

View File

@@ -24,7 +24,7 @@ exports[`<FetchIndicator /> matches snapshot when idle 1`] = `
<svg aria-hidden=\\"true\\"
data-prefix=\\"fas\\"
data-icon=\\"circle-notch\\"
class=\\"svg-inline--fa fa-circle-notch fa-w-16 fa-spin fa-lg mx-1 text-success\\"
class=\\"svg-inline--fa fa-circle-notch fa-w-16 fa-lg mx-1 text-muted\\"
role=\\"img\\"
xmlns=\\"http://www.w3.org/2000/svg\\"
viewbox=\\"0 0 512 512\\"
@@ -38,6 +38,25 @@ exports[`<FetchIndicator /> matches snapshot when idle 1`] = `
"
`;
exports[`<FetchIndicator /> matches snapshot when paused 1`] = `
"
<svg aria-hidden=\\"true\\"
data-prefix=\\"far\\"
data-icon=\\"pause-circle\\"
class=\\"svg-inline--fa fa-pause-circle fa-w-16 fa-lg mx-1 text-muted\\"
role=\\"img\\"
xmlns=\\"http://www.w3.org/2000/svg\\"
viewbox=\\"0 0 512 512\\"
style=\\"opacity: 1;\\"
>
<path fill=\\"currentColor\\"
d=\\"M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm0 448c-110.5 0-200-89.5-200-200S145.5 56 256 56s200 89.5 200 200-89.5 200-200 200zm96-280v160c0 8.8-7.2 16-16 16h-48c-8.8 0-16-7.2-16-16V176c0-8.8 7.2-16 16-16h48c8.8 0 16 7.2 16 16zm-112 0v160c0 8.8-7.2 16-16 16h-48c-8.8 0-16-7.2-16-16V176c0-8.8 7.2-16 16-16h48c8.8 0 16 7.2 16 16z\\"
>
</path>
</svg>
"
`;
exports[`<FetchIndicator /> matches snapshot when response is processed 1`] = `
"
<svg aria-hidden=\\"true\\"

View File

@@ -1,37 +1,57 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
import { observer } from "mobx-react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCircleNotch } from "@fortawesome/free-solid-svg-icons/faCircleNotch";
import { faPauseCircle } from "@fortawesome/free-regular-svg-icons/faPauseCircle";
import { AlertStoreStatuses } from "Stores/AlertStore";
class FetchIndicator extends Component {
static propTypes = {
status: PropTypes.string.isRequired
};
const FetchIcon = ({ icon, color, visible, spin }) => (
<FontAwesomeIcon
style={{ opacity: visible ? 1 : 0 }}
className={`mx-1 text-${color}`}
size="lg"
icon={icon}
spin={spin}
/>
);
FetchIcon.propTypes = {
icon: PropTypes.object.isRequired,
color: PropTypes.string,
visible: PropTypes.bool,
spin: PropTypes.bool
};
FetchIcon.defaultProps = {
color: "muted",
visible: true,
spin: false
};
render() {
const { status } = this.props;
const FetchIndicator = observer(
class FetchIndicator extends Component {
static propTypes = {
alertStore: PropTypes.object.isRequired
};
const visible =
status === AlertStoreStatuses.Fetching.toString() ||
status === AlertStoreStatuses.Processing.toString();
const textClass =
status === AlertStoreStatuses.Fetching.toString()
? "text-muted"
: "text-success";
render() {
const { alertStore } = this.props;
return (
<FontAwesomeIcon
style={{ opacity: visible ? 1 : 0 }}
className={`mx-1 ${textClass}`}
icon={faCircleNotch}
size="lg"
spin
/>
);
if (alertStore.status.paused) return <FetchIcon icon={faPauseCircle} />;
const status = alertStore.status.value.toString();
if (status === AlertStoreStatuses.Fetching.toString())
return <FetchIcon icon={faCircleNotch} spin />;
if (status === AlertStoreStatuses.Processing.toString())
return <FetchIcon icon={faCircleNotch} color="success" spin />;
return <FetchIcon icon={faCircleNotch} visible={false} />;
}
}
}
);
export { FetchIndicator };

View File

@@ -5,69 +5,88 @@ import { mount } from "enzyme";
import toDiffableHtml from "diffable-html";
import { FetchIndicator } from ".";
import { AlertStoreStatuses } from "Stores/AlertStore";
import { AlertStore } from "Stores/AlertStore";
let alertStore;
beforeEach(() => {
alertStore = new AlertStore([]);
});
const MountedFetchIndicator = () => {
return mount(<FetchIndicator alertStore={alertStore} />);
};
describe("<FetchIndicator />", () => {
it("shows a pause icon when fetching is paused", () => {
alertStore.status.pause();
const tree = MountedFetchIndicator();
expect(tree.html()).toMatch(/fa-pause-circle/);
});
it("shows a cirle notch icon when fetching is resumed", () => {
alertStore.status.resume();
const tree = MountedFetchIndicator();
expect(tree.html()).toMatch(/fa-circle-notch/);
});
it("opacity is 1 when fetch is in progress", () => {
const tree = mount(
<FetchIndicator status={AlertStoreStatuses.Fetching.toString()} />
);
alertStore.status.setFetching();
const tree = MountedFetchIndicator();
expect(tree.find("FontAwesomeIcon").props().style.opacity).toEqual(1);
});
it("uses text-muted when fetch is in progress", () => {
const tree = mount(
<FetchIndicator status={AlertStoreStatuses.Fetching.toString()} />
);
alertStore.status.setFetching();
const tree = MountedFetchIndicator();
expect(tree.find("FontAwesomeIcon").hasClass("text-muted")).toBe(true);
});
it("opacity is 1 when response is processed", () => {
const tree = mount(
<FetchIndicator status={AlertStoreStatuses.Processing.toString()} />
);
alertStore.status.setProcessing();
const tree = MountedFetchIndicator();
expect(tree.find("FontAwesomeIcon").props().style.opacity).toEqual(1);
});
it("uses text-success when response is processed", () => {
const tree = mount(
<FetchIndicator status={AlertStoreStatuses.Processing.toString()} />
);
alertStore.status.setProcessing();
const tree = MountedFetchIndicator();
expect(tree.find("FontAwesomeIcon").hasClass("text-success")).toBe(true);
});
it("opacity is 0 when idle", () => {
const tree = mount(
<FetchIndicator status={AlertStoreStatuses.Idle.toString()} />
);
alertStore.status.setIdle();
const tree = MountedFetchIndicator();
expect(tree.find("FontAwesomeIcon").props().style.opacity).toEqual(0);
});
it("opacity is 0 when fetch failed", () => {
const tree = mount(
<FetchIndicator status={AlertStoreStatuses.Failure.toString()} />
);
alertStore.status.setFailure();
const tree = MountedFetchIndicator();
expect(tree.find("FontAwesomeIcon").props().style.opacity).toEqual(0);
});
it("matches snapshot when paused", () => {
alertStore.status.pause();
const tree = MountedFetchIndicator();
expect(toDiffableHtml(tree.html())).toMatchSnapshot();
});
it("matches snapshot when fetch is in progress", () => {
const tree = mount(
<FetchIndicator status={AlertStoreStatuses.Fetching.toString()} />
);
alertStore.status.setFetching();
const tree = MountedFetchIndicator();
expect(toDiffableHtml(tree.html())).toMatchSnapshot();
});
it("matches snapshot when response is processed", () => {
const tree = mount(
<FetchIndicator status={AlertStoreStatuses.Processing.toString()} />
);
alertStore.status.setProcessing();
const tree = MountedFetchIndicator();
expect(toDiffableHtml(tree.html())).toMatchSnapshot();
});
it("matches snapshot when idle", () => {
const tree = mount(
<FetchIndicator status={AlertStoreStatuses.Idle.toString()} />
);
alertStore.status.setIdle();
const tree = MountedFetchIndicator();
expect(toDiffableHtml(tree.html())).toMatchSnapshot();
});
});

View File

@@ -72,7 +72,7 @@ const NavBar = observer(
<ReactResizeDetector handleHeight onResize={NavbarOnResize} />
<span className="navbar-brand my-0 mx-2 h1 d-none d-sm-block float-left">
{alertStore.info.totalAlerts}
<FetchIndicator status={alertStore.status.value.toString()} />
<FetchIndicator alertStore={alertStore} />
</span>
<ul className={`navbar-nav float-right d-flex ${flexClass}`}>
<SilenceModal

View File

@@ -172,6 +172,7 @@ class AlertStore {
{
value: AlertStoreStatuses.Idle,
error: null,
paused: false,
setIdle() {
this.value = AlertStoreStatuses.Idle;
this.error = null;
@@ -187,13 +188,21 @@ class AlertStore {
setFailure(err) {
this.value = AlertStoreStatuses.Failure;
this.error = err;
},
pause() {
this.paused = true;
},
resume() {
this.paused = false;
}
},
{
setIdle: action,
setFetching: action,
setProcessing: action,
setFailure: action
setFailure: action,
pause: action,
resume: action
},
{ name: "Store status" }
);