diff --git a/ui/src/Components/Grid/AlertGrid/index.js b/ui/src/Components/Grid/AlertGrid/index.js index abe63a535..c29d11536 100644 --- a/ui/src/Components/Grid/AlertGrid/index.js +++ b/ui/src/Components/Grid/AlertGrid/index.js @@ -4,6 +4,8 @@ import PropTypes from "prop-types"; import { observable, action } from "mobx"; import { observer } from "mobx-react"; +import moment from "moment"; + import MasonryInfiniteScroller from "react-masonry-infinite"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -67,6 +69,42 @@ const AlertGrid = observer( ); }); + compare = (a, b) => { + const { settingsStore } = this.props; + + // don't sort if sorting is disabled + if ( + settingsStore.gridConfig.config.sortOrder === + settingsStore.gridConfig.options.disabled.value + ) + return 0; + + const getLabelValue = g => { + // if timestamp sort is enabled use latest alert for sorting + if ( + settingsStore.gridConfig.config.sortOrder === + settingsStore.gridConfig.options.startsAt.value + ) { + return moment.max(g.alerts.map(a => moment(a.startsAt))); + } + + const label = settingsStore.gridConfig.config.sortLabel; + return g.labels[label] || g.alerts[0].labels[label] || ""; + }; + + const av = getLabelValue(a); + const bv = getLabelValue(b); + + const val = settingsStore.gridConfig.config.reverseSort ? -1 : 1; + if (av > bv) { + return val; + } else if (av < bv) { + return val * -1; + } else { + return 0; + } + }; + componentDidUpdate() { // whenever grid component re-renders we need to ensure that grid elements // are packed correctly @@ -94,13 +132,13 @@ const AlertGrid = observer( } > - {Object.keys(alertStore.data.groups) - .sort() + {Object.values(alertStore.data.groups) + .sort(this.compare) .slice(0, this.groupsToRender.value) - .map(id => ( + .map(group => ( 1 } diff --git a/ui/src/Components/Grid/AlertGrid/index.test.js b/ui/src/Components/Grid/AlertGrid/index.test.js index 29dbf4985..9daa346c3 100644 --- a/ui/src/Components/Grid/AlertGrid/index.test.js +++ b/ui/src/Components/Grid/AlertGrid/index.test.js @@ -46,12 +46,12 @@ const MockGroup = (groupName, alertCount) => { return group; }; -const MockGroupList = count => { +const MockGroupList = (count, alertPerGroup) => { let groups = {}; for (let i = 1; i <= count; i++) { let id = `id${i}`; let hash = `hash${i}`; - let group = MockGroup(`group${i}`, count); + let group = MockGroup(`group${i}`, alertPerGroup); group.id = id; group.hash = hash; groups[id] = group; @@ -66,14 +66,14 @@ const MockGroupList = count => { describe("", () => { it("renders only first 50 alert groups", () => { - MockGroupList(60); + MockGroupList(60, 5); const tree = ShallowAlertGrid(); const alertGroups = tree.find("AlertGroup"); expect(alertGroups).toHaveLength(50); }); it("appends 30 groups after loadMore() call", () => { - MockGroupList(100); + MockGroupList(100, 5); const tree = ShallowAlertGrid(); // call it directly, it should happen on scroll to the bottom of the page tree.instance().loadMore(); @@ -107,6 +107,127 @@ describe("", () => { const tree = ShallowAlertGrid(); const instance = tree.instance(); instance.storeMasonryRef("foo"); - expect(instance.masonryComponentReference.ref).toBe("foo"); + expect(instance.masonryComponentReference.ref).toEqual("foo"); + }); + + it("doesn't sort groups when sorting is set to 'disabled'", () => { + settingsStore.gridConfig.config.sortOrder = + settingsStore.gridConfig.options.disabled.value; + settingsStore.gridConfig.config.reverseSort = false; + MockGroupList(3, 1); + const tree = ShallowAlertGrid(); + const alertGroups = tree.find("AlertGroup"); + expect(alertGroups.map(g => g.props().group.id)).toEqual([ + "id1", + "id2", + "id3" + ]); + }); + + it("doesn't sort groups when sorting is set to 'disabled' and 'reverse' is on", () => { + settingsStore.gridConfig.config.sortOrder = + settingsStore.gridConfig.options.disabled.value; + settingsStore.gridConfig.config.reverseSort = true; + MockGroupList(3, 1); + const tree = ShallowAlertGrid(); + const alertGroups = tree.find("AlertGroup"); + expect(alertGroups.map(g => g.props().group.id)).toEqual([ + "id1", + "id2", + "id3" + ]); + }); + + it("groups are sorted by timestamp when sorting is set to 'startsAt'", () => { + settingsStore.gridConfig.config.sortOrder = + settingsStore.gridConfig.options.startsAt.value; + settingsStore.gridConfig.config.reverseSort = false; + + MockGroupList(3, 1); + alertStore.data.groups.id1.alerts[0].startsAt = "2001-01-01T00:00:00Z"; + alertStore.data.groups.id2.alerts[0].startsAt = "2002-01-01T00:00:00Z"; + alertStore.data.groups.id3.alerts[0].startsAt = "2000-01-01T00:00:00Z"; + + const tree = ShallowAlertGrid(); + const alertGroups = tree.find("AlertGroup"); + expect(alertGroups.map(g => g.props().group.id)).toEqual([ + "id3", + "id1", + "id2" + ]); + }); + + it("groups are sorted by reversed timestamp when sorting is set to 'startsAt' and 'reverse' is on", () => { + settingsStore.gridConfig.config.sortOrder = + settingsStore.gridConfig.options.startsAt.value; + settingsStore.gridConfig.config.reverseSort = true; + + MockGroupList(3, 1); + alertStore.data.groups.id1.alerts[0].startsAt = "2001-01-01T00:00:00Z"; + alertStore.data.groups.id2.alerts[0].startsAt = "2002-01-01T00:00:00Z"; + alertStore.data.groups.id3.alerts[0].startsAt = "2000-01-01T00:00:00Z"; + + const tree = ShallowAlertGrid(); + const alertGroups = tree.find("AlertGroup"); + expect(alertGroups.map(g => g.props().group.id)).toEqual([ + "id2", + "id1", + "id3" + ]); + }); + + it("groups are sorted by label when sorting is set to 'label'", () => { + settingsStore.gridConfig.config.sortOrder = + settingsStore.gridConfig.options.label.value; + settingsStore.gridConfig.config.sortLabel = "instance"; + settingsStore.gridConfig.config.reverseSort = false; + + MockGroupList(3, 1); + alertStore.data.groups.id1.alerts[0].labels.instance = "abc1"; + alertStore.data.groups.id2.alerts[0].labels.instance = "abc3"; + alertStore.data.groups.id3.alerts[0].labels.instance = "abc2"; + + const tree = ShallowAlertGrid(); + const alertGroups = tree.find("AlertGroup"); + expect(alertGroups.map(g => g.props().group.id)).toEqual([ + "id1", + "id3", + "id2" + ]); + }); + + it("groups are sorted by reverse label when sorting is set to 'label' and 'reverse' is on", () => { + settingsStore.gridConfig.config.sortOrder = + settingsStore.gridConfig.options.label.value; + settingsStore.gridConfig.config.sortLabel = "instance"; + settingsStore.gridConfig.config.reverseSort = true; + + MockGroupList(3, 1); + alertStore.data.groups.id1.alerts[0].labels.instance = "abc1"; + alertStore.data.groups.id2.alerts[0].labels.instance = "abc3"; + alertStore.data.groups.id3.alerts[0].labels.instance = "abc2"; + + const tree = ShallowAlertGrid(); + const alertGroups = tree.find("AlertGroup"); + expect(alertGroups.map(g => g.props().group.id)).toEqual([ + "id2", + "id3", + "id1" + ]); + }); + + it("sorting is no-op when when sorting is set to 'label' and alerts lack that label", () => { + settingsStore.gridConfig.config.sortOrder = + settingsStore.gridConfig.options.label.value; + settingsStore.gridConfig.config.sortLabel = "foo"; + settingsStore.gridConfig.config.reverseSort = false; + MockGroupList(3, 1); + const tree = ShallowAlertGrid(); + const alertGroups = tree.find("AlertGroup"); + expect(alertGroups.map(g => g.props().group.id)).toEqual([ + "id1", + "id2", + "id3" + ]); }); }); diff --git a/ui/src/Components/MainModal/Configuration/AlertGroupConfiguration.test.js b/ui/src/Components/MainModal/Configuration/AlertGroupConfiguration.test.js index b9525489c..cd9832529 100644 --- a/ui/src/Components/MainModal/Configuration/AlertGroupConfiguration.test.js +++ b/ui/src/Components/MainModal/Configuration/AlertGroupConfiguration.test.js @@ -16,7 +16,7 @@ const FakeConfiguration = () => { return mount(); }; -describe(" className", () => { +describe("", () => { it("matches snapshot with default values", () => { const tree = FakeConfiguration(); expect(toDiffableHtml(tree.html())).toMatchSnapshot(); diff --git a/ui/src/Components/MainModal/Configuration/AlertGroupSortConfiguration.js b/ui/src/Components/MainModal/Configuration/AlertGroupSortConfiguration.js new file mode 100644 index 000000000..9938a744c --- /dev/null +++ b/ui/src/Components/MainModal/Configuration/AlertGroupSortConfiguration.js @@ -0,0 +1,109 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; + +import { action } from "mobx"; +import { observer } from "mobx-react"; + +import ReactSelect from "react-select"; + +import { Settings } from "Stores/Settings"; +import { ReactSelectStyles } from "Components/MultiSelect"; +import { SortLabelName } from "./SortLabelName"; + +const AlertGroupSortConfiguration = observer( + class AlertGroupSortConfiguration extends Component { + static propTypes = { + settingsStore: PropTypes.instanceOf(Settings).isRequired + }; + + constructor(props) { + super(props); + + this.validateConfig(); + } + + onSortOrderChange = action((newValue, actionMeta) => { + const { settingsStore } = this.props; + + settingsStore.gridConfig.config.sortOrder = newValue.value; + }); + + onSortReverseChange = action(event => { + const { settingsStore } = this.props; + + settingsStore.gridConfig.config.reverseSort = event.target.checked; + }); + + valueToOption = val => { + const { settingsStore } = this.props; + + return { label: settingsStore.gridConfig.options[val].label, value: val }; + }; + + validateConfig = action(() => { + const { settingsStore } = this.props; + + if ( + !Object.values(settingsStore.gridConfig.options) + .map(o => o.value) + .includes(settingsStore.gridConfig.config.sortOrder) + ) { + settingsStore.gridConfig.config.sortOrder = + settingsStore.gridConfig.defaults.sortOrder; + } + }); + + render() { + const { settingsStore } = this.props; + + return ( +
+
+ +
+
+
+ +
+ {settingsStore.gridConfig.config.sortOrder === + settingsStore.gridConfig.options.label.value ? ( +
+ +
+ ) : null} +
+ + + + +
+
+
+ ); + } + } +); + +export { AlertGroupSortConfiguration }; diff --git a/ui/src/Components/MainModal/Configuration/AlertGroupSortConfiguration.test.js b/ui/src/Components/MainModal/Configuration/AlertGroupSortConfiguration.test.js new file mode 100644 index 000000000..94542c916 --- /dev/null +++ b/ui/src/Components/MainModal/Configuration/AlertGroupSortConfiguration.test.js @@ -0,0 +1,141 @@ +import React from "react"; + +import { mount } from "enzyme"; + +import toDiffableHtml from "diffable-html"; + +import { Settings } from "Stores/Settings"; +import { AlertGroupSortConfiguration } from "./AlertGroupSortConfiguration"; + +let settingsStore; +beforeEach(() => { + fetch.mockResponse(JSON.stringify([])); + settingsStore = new Settings(); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +const FakeConfiguration = () => { + return mount(); +}; + +const ExpandSortLabelSuggestions = async () => { + settingsStore.gridConfig.config.sortOrder = + settingsStore.gridConfig.options.label.value; + const tree = FakeConfiguration(); + const labelSelect = tree.find("SortLabelName"); + await expect( + labelSelect.instance().nameSuggestionsFetch + ).resolves.toBeUndefined(); + + tree + .find("input#react-select-configuration-sort-label-input") + .simulate("change", { target: { value: "a" } }); + + fetch.resetMocks(); + return tree; +}; + +describe("", () => { + it("matches snapshot with default values", () => { + const tree = FakeConfiguration(); + expect(toDiffableHtml(tree.html())).toMatchSnapshot(); + }); + + it("invalid sortOrder value is reset on mount", done => { + settingsStore.gridConfig.config.sortOrder = "badValue"; + FakeConfiguration(); + setTimeout(() => { + expect(settingsStore.gridConfig.config.sortOrder).toBe( + settingsStore.gridConfig.defaults.sortOrder + ); + done(); + }, 200); + }); + + it("changing sort order value update settingsStore", async done => { + settingsStore.gridConfig.config.sortOrder = + settingsStore.gridConfig.options.label.value; + expect(settingsStore.gridConfig.config.sortOrder).toBe( + settingsStore.gridConfig.options.label.value + ); + const tree = FakeConfiguration(); + tree.instance().onSortOrderChange({ + label: settingsStore.gridConfig.options.startsAt.label, + value: settingsStore.gridConfig.options.startsAt.value + }); + setTimeout(() => { + expect(settingsStore.gridConfig.config.sortOrder).toBe( + settingsStore.gridConfig.options.startsAt.value + ); + done(); + }, 200); + }); + + it("label select is not rendered when sort order is != 'label'", () => { + settingsStore.gridConfig.config.sortOrder = + settingsStore.gridConfig.options.disabled.value; + const tree = FakeConfiguration(); + const labelSelect = tree.find("SortLabelName"); + expect(labelSelect).toHaveLength(0); + }); + + it("label select is rendered when sort order is == 'label'", () => { + settingsStore.gridConfig.config.sortOrder = + settingsStore.gridConfig.options.label.value; + const tree = FakeConfiguration(); + const labelSelect = tree.find("SortLabelName"); + expect(labelSelect).toHaveLength(1); + }); + + it("label select renders suggestions on click", async () => { + fetch.mockResponse(JSON.stringify(["alertname", "cluster", "fakeLabel"])); + const tree = await ExpandSortLabelSuggestions(); + const options = tree.find(".react-select__option"); + expect(options).toHaveLength(3); + expect(options.at(0).text()).toBe("alertname"); + expect(options.at(1).text()).toBe("cluster"); + expect(options.at(2).text()).toBe("fakeLabel"); + }); + + it("label select handles fetch errors", async () => { + fetch.mockReject("error"); + const tree = await ExpandSortLabelSuggestions(); + const options = tree.find(".react-select__option"); + expect(options).toHaveLength(0); + }); + + it("label select handles invalid JSON", async () => { + jest.spyOn(console, "error").mockImplementation(() => {}); + fetch.mockResponse("invalid JSON"); + const tree = await ExpandSortLabelSuggestions(); + const options = tree.find(".react-select__option"); + expect(options).toHaveLength(0); + }); + + it("clicking on a label option updates settingsStore", async done => { + fetch.mockResponse(JSON.stringify(["alertname", "cluster", "fakeLabel"])); + const tree = await ExpandSortLabelSuggestions(); + const options = tree.find(".react-select__option"); + options.at(1).simulate("click"); + setTimeout(() => { + expect(settingsStore.gridConfig.config.sortLabel).toBe("cluster"); + done(); + }, 200); + }); + + it("clicking on the 'reverse' checkbox updates settingsStore", done => { + settingsStore.gridConfig.config.reverseSort = false; + const tree = FakeConfiguration(); + const checkbox = tree.find("#configuration-sort-reverse"); + + expect(settingsStore.gridConfig.config.reverseSort).toBe(false); + checkbox.simulate("change", { target: { checked: true } }); + setTimeout(() => { + expect(settingsStore.gridConfig.config.reverseSort).toBe(true); + done(); + }, 200); + }); +}); diff --git a/ui/src/Components/MainModal/Configuration/FetchConfiguration.test.js b/ui/src/Components/MainModal/Configuration/FetchConfiguration.test.js index 97c1b152f..9c9d87f03 100644 --- a/ui/src/Components/MainModal/Configuration/FetchConfiguration.test.js +++ b/ui/src/Components/MainModal/Configuration/FetchConfiguration.test.js @@ -16,7 +16,7 @@ const FakeConfiguration = () => { return mount(); }; -describe(" className", () => { +describe("", () => { it("matches snapshot with default values", () => { const tree = FakeConfiguration(); expect(toDiffableHtml(tree.html())).toMatchSnapshot(); diff --git a/ui/src/Components/MainModal/Configuration/SortLabelName.js b/ui/src/Components/MainModal/Configuration/SortLabelName.js new file mode 100644 index 000000000..cc49f0f0f --- /dev/null +++ b/ui/src/Components/MainModal/Configuration/SortLabelName.js @@ -0,0 +1,76 @@ +import React from "react"; +import PropTypes from "prop-types"; + +import { action, observable } from "mobx"; +import { observer } from "mobx-react"; + +import CreatableSelect from "react-select/lib/Creatable"; + +import { FormatBackendURI } from "Stores/AlertStore"; +import { Settings } from "Stores/Settings"; +import { ReactSelectStyles } from "Components/MultiSelect"; + +const valueToOption = v => ({ label: v, value: v }); + +const SortLabelName = observer( + class SortLabelName extends CreatableSelect { + static propTypes = { + settingsStore: PropTypes.instanceOf(Settings).isRequired + }; + + suggestions = observable({ + names: [] + }); + + populateNameSuggestions = action(() => { + this.nameSuggestionsFetch = fetch(FormatBackendURI(`labelNames.json`), { + credentials: "include" + }) + .then( + result => result.json(), + err => { + return []; + } + ) + .then(result => { + this.suggestions.names = result.map(value => ({ + label: value, + value: value + })); + }) + .catch(err => { + console.error(err.message); + this.suggestions.names = []; + }); + }); + + onChange = action((newValue, actionMeta) => { + const { settingsStore } = this.props; + + settingsStore.gridConfig.config.sortLabel = newValue.value; + }); + + componentDidMount() { + this.populateNameSuggestions(); + } + + render() { + const { settingsStore } = this.props; + + return ( + + ); + } + } +); + +export { SortLabelName }; diff --git a/ui/src/Components/MainModal/Configuration/__snapshots__/AlertGroupConfiguration.test.js.snap b/ui/src/Components/MainModal/Configuration/__snapshots__/AlertGroupConfiguration.test.js.snap index 5ced2b986..bcb3b0643 100644 --- a/ui/src/Components/MainModal/Configuration/__snapshots__/AlertGroupConfiguration.test.js.snap +++ b/ui/src/Components/MainModal/Configuration/__snapshots__/AlertGroupConfiguration.test.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` className matches snapshot with default values 1`] = ` +exports[` matches snapshot with default values 1`] = ` "