From da6368288a9f5f55bb88cb95260d656c05328a9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Fri, 15 Feb 2019 15:55:37 +0000 Subject: [PATCH] feat(ui): allow sorting alert grid This adds the ability for user to sort the grid of alerts via selected attribute. UI configuration is provided to setup if timestamps or labels should be used to sort alerts. --- ui/src/Components/Grid/AlertGrid/index.js | 48 +++++- .../Components/Grid/AlertGrid/index.test.js | 131 +++++++++++++++- .../AlertGroupConfiguration.test.js | 2 +- .../AlertGroupSortConfiguration.js | 109 ++++++++++++++ .../AlertGroupSortConfiguration.test.js | 141 ++++++++++++++++++ .../Configuration/FetchConfiguration.test.js | 2 +- .../MainModal/Configuration/SortLabelName.js | 76 ++++++++++ .../AlertGroupConfiguration.test.js.snap | 2 +- .../AlertGroupSortConfiguration.test.js.snap | 78 ++++++++++ .../FetchConfiguration.test.js.snap | 2 +- .../MainModal/Configuration/index.js | 3 + .../MainModal/Configuration/index.test.js | 2 +- .../MainModalContent.test.js.snap | 74 +++++++++ ui/src/Stores/Settings.js | 25 ++++ 14 files changed, 680 insertions(+), 15 deletions(-) create mode 100644 ui/src/Components/MainModal/Configuration/AlertGroupSortConfiguration.js create mode 100644 ui/src/Components/MainModal/Configuration/AlertGroupSortConfiguration.test.js create mode 100644 ui/src/Components/MainModal/Configuration/SortLabelName.js create mode 100644 ui/src/Components/MainModal/Configuration/__snapshots__/AlertGroupSortConfiguration.test.js.snap 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`] = ` "