feat(ui): show modal with list of inhibiting alerts

This commit is contained in:
Łukasz Mierzwa
2020-06-30 22:15:15 +01:00
committed by Łukasz Mierzwa
parent 6f1fb98248
commit b1cf816e24
14 changed files with 449 additions and 164 deletions

View File

@@ -8,6 +8,7 @@ const StaticLabels = Object.freeze({
AlertName: "alertname",
AlertManager: "@alertmanager",
AlertmanagerCluster: "@cluster",
Fingerprint: "@fingerprint",
Receiver: "@receiver",
State: "@state",
SilenceID: "@silence_id",

View File

@@ -81,7 +81,7 @@ exports[`<Alert /> matches snapshot when inhibited 1`] = `
<div style=\\"display: inline-block; max-width: 100%;\\"
class=\\" tooltip-trigger\\"
>
<span class=\\"badge badge-light components-label\\">
<span class=\\"badge badge-light components-label components-label-with-hover cursor-pointer\\">
<svg aria-hidden=\\"true\\"
focusable=\\"false\\"
data-prefix=\\"fas\\"

View File

@@ -3,16 +3,13 @@ import PropTypes from "prop-types";
import { useObserver } from "mobx-react-lite";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faVolumeMute } from "@fortawesome/free-solid-svg-icons/faVolumeMute";
import { APIAlert, APIGroup } from "Models/API";
import { AlertStore } from "Stores/AlertStore";
import { SilenceFormStore } from "Stores/SilenceFormStore";
import { BorderClassMap } from "Common/Colors";
import { StaticLabels } from "Common/Query";
import { FilteringLabel } from "Components/Labels/FilteringLabel";
import { TooltipWrapper } from "Components/TooltipWrapper";
import { InhibitedByModal } from "Components/InhibitedByModal";
import { RenderNonLinkAnnotation, RenderLinkAnnotation } from "../Annotation";
import { AlertMenu } from "./AlertMenu";
import { RenderSilence } from "../Silences";
@@ -39,13 +36,15 @@ const Alert = ({
const silences = {};
let clusters = [];
let isInhibited = false;
let inhibitedBy = [];
for (const am of alert.alertmanager) {
if (!clusters.includes(am.cluster)) {
clusters.push(am.cluster);
}
if (am.inhibitedBy.length > 0) {
isInhibited = true;
for (const fingerprint of am.inhibitedBy) {
if (!inhibitedBy.includes(fingerprint)) {
inhibitedBy.push(fingerprint);
}
}
if (!silences[am.cluster]) {
silences[am.cluster] = {
@@ -87,12 +86,8 @@ const Alert = ({
silenceFormStore={silenceFormStore}
setIsMenuOpen={setIsMenuOpen}
/>
{isInhibited ? (
<TooltipWrapper title="This alert is inhibited by other alerts">
<span className="badge badge-light components-label">
<FontAwesomeIcon className="text-success" icon={faVolumeMute} />
</span>
</TooltipWrapper>
{inhibitedBy.length > 0 ? (
<InhibitedByModal alertStore={alertStore} fingerprints={inhibitedBy} />
) : null}
{Object.entries(alert.labels).map(([name, value]) => (
<FilteringLabel

View File

@@ -86,6 +86,23 @@ describe("<Alert />", () => {
});
it("renders inhibition icon when inhibited", () => {
const alert = MockedAlert();
alert.alertmanager[0].inhibitedBy = ["123456"];
alert.alertmanager.push({
name: "ha2",
cluster: "HA",
state: "active",
startsAt: "2018-08-14T17:36:40.017867056Z",
source: "localhost/prometheus",
silencedBy: [],
inhibitedBy: ["123456"],
});
const group = MockAlertGroup({}, [alert], [], {}, {});
const tree = MountedAlert(alert, group, false, false);
expect(tree.find(".fa-volume-mute")).toHaveLength(1);
});
it("inhibition icon passes only unique fingerprints", () => {
const alert = MockedAlert();
alert.alertmanager[0].inhibitedBy = ["123456"];
const group = MockAlertGroup({}, [alert], [], {}, {});

View File

@@ -0,0 +1,38 @@
import React from "react";
import PropTypes from "prop-types";
import { AlertStore } from "Stores/AlertStore";
import { FormatQuery, QueryOperators, StaticLabels } from "Common/Query";
import { PaginatedAlertList } from "Components/PaginatedAlertList";
const InhibitedByModalContent = ({ alertStore, fingerprints, onHide }) => {
return (
<React.Fragment>
<div className="modal-header">
<h5 className="modal-title">Inhibiting alerts</h5>
<button type="button" className="close" onClick={onHide}>
<span className="align-middle">&times;</span>
</button>
</div>
<div className="modal-body">
<PaginatedAlertList
alertStore={alertStore}
filters={[
FormatQuery(
StaticLabels.Fingerprint,
QueryOperators.Regex,
`^(${fingerprints.join("|")})$`
),
]}
/>
</div>
</React.Fragment>
);
};
InhibitedByModalContent.propTypes = {
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
fingerprints: PropTypes.arrayOf(PropTypes.string).isRequired,
onHide: PropTypes.func.isRequired,
};
export { InhibitedByModalContent };

View File

@@ -0,0 +1,58 @@
import React, { useState, useCallback } from "react";
import PropTypes from "prop-types";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSpinner } from "@fortawesome/free-solid-svg-icons/faSpinner";
import { faVolumeMute } from "@fortawesome/free-solid-svg-icons/faVolumeMute";
import { AlertStore } from "Stores/AlertStore";
import { TooltipWrapper } from "Components/TooltipWrapper";
import { Modal } from "Components/Modal";
// https://github.com/facebook/react/issues/14603
const InhibitedByModalContent = React.lazy(() =>
import("./InhibitedByModalContent").then((module) => ({
default: module.InhibitedByModalContent,
}))
);
const InhibitedByModal = ({ alertStore, fingerprints }) => {
const [isVisible, setIsVisible] = useState(false);
const toggle = useCallback(() => setIsVisible(!isVisible), [isVisible]);
return (
<React.Fragment>
<TooltipWrapper title="This alert is inhibited by other alerts, click to see details">
<span
className="badge badge-light components-label components-label-with-hover cursor-pointer"
onClick={toggle}
>
<FontAwesomeIcon className="text-success" icon={faVolumeMute} />
</span>
</TooltipWrapper>
<Modal size="lg" isOpen={isVisible} toggleOpen={toggle}>
<React.Suspense
fallback={
<h1 className="display-1 text-placeholder p-5 m-auto">
<FontAwesomeIcon icon={faSpinner} size="lg" spin />
</h1>
}
>
<InhibitedByModalContent
alertStore={alertStore}
onHide={() => setIsVisible(false)}
isVisible={isVisible}
fingerprints={fingerprints}
/>
</React.Suspense>
</Modal>
</React.Fragment>
);
};
InhibitedByModal.propTypes = {
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
fingerprints: PropTypes.arrayOf(PropTypes.string).isRequired,
};
export { InhibitedByModal };

View File

@@ -0,0 +1,107 @@
import React from "react";
import { act } from "react-dom/test-utils";
import { mount } from "enzyme";
import { AlertStore } from "Stores/AlertStore";
import { InhibitedByModal } from ".";
let alertStore;
beforeAll(() => {
jest.useFakeTimers();
});
beforeEach(() => {
alertStore = new AlertStore([]);
});
afterEach(() => {
document.body.className = "";
});
describe("<InhibitedByModal />", () => {
it("renders a spinner placeholder while modal content is loading", () => {
const tree = mount(
<InhibitedByModal alertStore={alertStore} fingerprints={["foo=bar"]} />
);
const toggle = tree.find("span.badge.badge-light");
toggle.simulate("click");
expect(tree.find("InhibitedByModalContent")).toHaveLength(0);
expect(tree.find(".modal-content").find("svg.fa-spinner")).toHaveLength(1);
});
it("renders modal content if fallback is not used", () => {
const tree = mount(
<InhibitedByModal alertStore={alertStore} fingerprints={["foo=bar"]} />
);
const toggle = tree.find("span.badge.badge-light");
toggle.simulate("click");
expect(tree.find(".modal-title").text()).toBe("Inhibiting alerts");
expect(tree.find(".modal-content").find("svg.fa-spinner")).toHaveLength(0);
});
it("hides the modal when toggle() is called twice", () => {
const tree = mount(
<InhibitedByModal alertStore={alertStore} fingerprints={["foo=bar"]} />
);
const toggle = tree.find("span.badge.badge-light");
toggle.simulate("click");
act(() => jest.runOnlyPendingTimers());
tree.update();
expect(tree.find(".modal-title").text()).toBe("Inhibiting alerts");
toggle.simulate("click");
act(() => jest.runOnlyPendingTimers());
tree.update();
expect(tree.find(".modal-title")).toHaveLength(0);
});
it("hides the modal when button.close is clicked", () => {
const tree = mount(
<InhibitedByModal alertStore={alertStore} fingerprints={["foo=bar"]} />
);
const toggle = tree.find("span.badge.badge-light");
toggle.simulate("click");
expect(tree.find(".modal-title").text()).toBe("Inhibiting alerts");
tree.find("button.close").simulate("click");
act(() => jest.runOnlyPendingTimers());
tree.update();
expect(tree.find("InhibitedByModalContent")).toHaveLength(0);
});
it("'modal-open' class is appended to body node when modal is visible", () => {
const tree = mount(
<InhibitedByModal alertStore={alertStore} fingerprints={["foo=bar"]} />
);
const toggle = tree.find("span.badge.badge-light");
toggle.simulate("click");
expect(document.body.className.split(" ")).toContain("modal-open");
});
it("'modal-open' class is removed from body node after modal is hidden", () => {
const tree = mount(
<InhibitedByModal alertStore={alertStore} fingerprints={["foo=bar"]} />
);
tree.find("span.badge.badge-light").simulate("click");
expect(document.body.className.split(" ")).toContain("modal-open");
tree.find("span.badge.badge-light").simulate("click");
act(() => jest.runOnlyPendingTimers());
expect(document.body.className.split(" ")).not.toContain("modal-open");
});
it("'modal-open' class is removed from body node after modal is unmounted", () => {
const tree = mount(
<InhibitedByModal alertStore={alertStore} fingerprints={["foo=bar"]} />
);
const toggle = tree.find("span.badge.badge-light");
toggle.simulate("click");
tree.unmount();
expect(document.body.className.split(" ")).not.toContain("modal-open");
});
});

View File

@@ -26,14 +26,14 @@ const GroupListToUniqueLabelsList = (groups) => {
return Object.values(alerts);
};
const LabelSetList = ({ alertStore, labelsList }) => {
const LabelSetList = ({ alertStore, labelsList, title }) => {
const [activePage, setActivePage] = useState(1);
const maxPerPage = IsMobile() ? 5 : 10;
return labelsList.length > 0 ? (
<div>
<p className="lead text-center">Affected alerts</p>
{title ? <p className="lead text-center">{title}</p> : null}
<div>
<ul className="list-group list-group-flush mb-3">
{labelsList
@@ -63,12 +63,17 @@ const LabelSetList = ({ alertStore, labelsList }) => {
/>
</div>
) : (
<p className="text-muted text-center">No alerts matched</p>
<div className="jumbotron bg-transparent">
<h1 className="display-5 text-placeholder text-center">
No alerts matched
</h1>
</div>
);
};
LabelSetList.propTypes = {
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
labelsList: PropTypes.arrayOf(PropTypes.object).isRequired,
title: PropTypes.string,
};
export { LabelSetList, GroupListToUniqueLabelsList };

View File

@@ -19,7 +19,11 @@ afterEach(() => {
const MountedLabelSetList = (labelsList) => {
return mount(
<LabelSetList alertStore={alertStore} labelsList={labelsList} />
<LabelSetList
alertStore={alertStore}
labelsList={labelsList}
title="Affected alerts"
/>
);
};

View File

@@ -8,16 +8,12 @@ import { faCheckCircle } from "@fortawesome/free-solid-svg-icons/faCheckCircle";
import { faCircleNotch } from "@fortawesome/free-solid-svg-icons/faCircleNotch";
import { APISilence } from "Models/API";
import { AlertStore, FormatBackendURI, FormatAlertsQ } from "Stores/AlertStore";
import { AlertStore } from "Stores/AlertStore";
import { SilenceFormStore } from "Stores/SilenceFormStore";
import { FormatQuery, QueryOperators, StaticLabels } from "Common/Query";
import { useFetchGet } from "Hooks/useFetchGet";
import { useFetchDelete } from "Hooks/useFetchDelete";
import { Modal } from "Components/Modal";
import {
LabelSetList,
GroupListToUniqueLabelsList,
} from "Components/LabelSetList";
import { PaginatedAlertList } from "Components/PaginatedAlertList";
const ProgressMessage = () => (
<div className="text-center">
@@ -55,32 +51,6 @@ const SuccessMessage = () => (
</div>
);
const DeletePreview = ({ alertStore, silence }) => {
const { response, error, isLoading } = useFetchGet(
FormatBackendURI("alerts.json?") +
FormatAlertsQ([
FormatQuery(StaticLabels.SilenceID, QueryOperators.Equal, silence.id),
])
);
return isLoading ? (
<ProgressMessage />
) : error ? (
<ErrorMessage message={error} />
) : (
<LabelSetList
alertStore={alertStore}
labelsList={GroupListToUniqueLabelsList(
response.grids.length ? response.grids[0].alertGroups : []
)}
/>
);
};
DeletePreview.propTypes = {
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
silence: APISilence.isRequired,
};
const DeleteResult = ({ alertStore, cluster, silence }) => {
const [currentTime, setCurrentTime] = useState(Math.floor(Date.now()));
@@ -164,7 +134,17 @@ const DeleteSilenceModalContent = ({
/>
) : (
<React.Fragment>
<DeletePreview alertStore={alertStore} silence={silence} />
<PaginatedAlertList
alertStore={alertStore}
filters={[
FormatQuery(
StaticLabels.SilenceID,
QueryOperators.Equal,
silence.id
),
]}
title="Affected alerts"
/>
<div className="d-flex flex-row-reverse">
<button
type="button"
@@ -245,9 +225,4 @@ DeleteSilence.defaultProps = {
isUpper: false,
};
export {
DeleteSilence,
DeleteSilenceModalContent,
DeletePreview,
DeleteResult,
};
export { DeleteSilence, DeleteSilenceModalContent, DeleteResult };

View File

@@ -5,7 +5,6 @@ import { mount } from "enzyme";
import { advanceTo, clear } from "jest-date-mock";
import { EmptyAPIResponse } from "__mocks__/Fetch";
import { MockSilence } from "__mocks__/Alerts";
import { PressKey } from "__mocks__/PressKey";
import { AlertStore } from "Stores/AlertStore";
@@ -137,68 +136,6 @@ describe("<DeleteSilenceModalContent />", () => {
expect(silenceFormStore.toggle.blurred).toBe(false);
});
it("renders ProgressMessage while loading preview", () => {
useFetchGet.fetch.setMockedData({
response: null,
error: false,
isLoading: true,
isRetrying: false,
});
const tree = MountedDeleteSilenceModalContent();
expect(tree.find("ProgressMessage")).toHaveLength(1);
});
it("renders LabelSetList with StaticLabel on mount", () => {
const tree = MountedDeleteSilenceModalContent();
expect(tree.find("LabelSetList")).toHaveLength(1);
expect(tree.find("StaticLabel")).toHaveLength(3);
});
it("renders empty LabelSetList with empty response", () => {
useFetchGet.fetch.setMockedData({
response: EmptyAPIResponse(),
error: false,
isLoading: false,
isRetrying: false,
});
const tree = MountedDeleteSilenceModalContent();
expect(tree.find("LabelSetList")).toHaveLength(1);
expect(tree.find("StaticLabel")).toHaveLength(0);
});
it("fetches affected alerts on mount", () => {
MountedDeleteSilenceModalContent();
expect(useFetchGet).toHaveBeenCalled();
});
it("renders StaticLabel after fetch", () => {
const tree = MountedDeleteSilenceModalContent();
expect(tree.text()).toMatch(/Affected alerts/);
expect(tree.find("StaticLabel")).toHaveLength(3);
});
it("handles empty grid response correctly", () => {
useFetchGet.fetch.setMockedData({
response: EmptyAPIResponse(),
error: false,
isLoading: false,
isRetrying: false,
});
const tree = MountedDeleteSilenceModalContent();
expect(tree.text()).toMatch(/No alerts matched/);
});
it("renders ErrorMessage on failed preview fetch", () => {
useFetchGet.fetch.setMockedData({
response: null,
error: "fake error",
isLoading: false,
isRetrying: false,
});
const tree = MountedDeleteSilenceModalContent();
expect(tree.find("ErrorMessage")).toHaveLength(1);
});
it("sends a DELETE request after clicking 'Confirm' button", () => {
const tree = MountedDeleteSilenceModalContent();
tree.find(".btn-danger").simulate("click");

View File

@@ -0,0 +1,60 @@
import React from "react";
import PropTypes from "prop-types";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons/faExclamationCircle";
import { faSpinner } from "@fortawesome/free-solid-svg-icons/faSpinner";
import { AlertStore, FormatBackendURI, FormatAlertsQ } from "Stores/AlertStore";
import {
LabelSetList,
GroupListToUniqueLabelsList,
} from "Components/LabelSetList";
import { useFetchGet } from "Hooks/useFetchGet";
const FetchError = ({ message }) => (
<div className="text-center">
<h2 className="display-2 text-danger">
<FontAwesomeIcon icon={faExclamationCircle} />
</h2>
<p className="lead text-muted">{message}</p>
</div>
);
FetchError.propTypes = {
message: PropTypes.node.isRequired,
};
const Placeholder = () => (
<div className="jumbotron bg-transparent">
<h1 className="display-5 text-placeholder text-center">
<FontAwesomeIcon icon={faSpinner} size="lg" spin />
</h1>
</div>
);
const PaginatedAlertList = ({ alertStore, filters, title }) => {
const { response, error, isLoading } = useFetchGet(
FormatBackendURI("alerts.json?") + FormatAlertsQ(filters)
);
return isLoading ? (
<Placeholder />
) : error ? (
<FetchError message={error} />
) : (
<LabelSetList
alertStore={alertStore}
labelsList={GroupListToUniqueLabelsList(
response.grids.length ? response.grids[0].alertGroups : []
)}
title={title}
/>
);
};
PaginatedAlertList.propTypes = {
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
filters: PropTypes.arrayOf(PropTypes.string).isRequired,
title: PropTypes.string,
};
export { PaginatedAlertList };

View File

@@ -0,0 +1,125 @@
import React from "react";
import { mount } from "enzyme";
import { advanceTo, clear } from "jest-date-mock";
import { EmptyAPIResponse } from "__mocks__/Fetch";
import { AlertStore } from "Stores/AlertStore";
import { useFetchGet } from "Hooks/useFetchGet";
import { useFetchDelete } from "Hooks/useFetchDelete";
import { PaginatedAlertList } from ".";
let alertStore;
beforeEach(() => {
advanceTo(new Date(Date.UTC(2000, 0, 1, 0, 30, 0)));
jest.useFakeTimers();
alertStore = new AlertStore([]);
alertStore.data.upstreams = {
instances: [
{
name: "am1",
cluster: "am",
uri: "http://localhost:9093",
readonly: false,
error: "",
version: "0.17.0",
headers: {},
corsCredentials: "include",
clusterMembers: ["am1"],
},
],
clusters: { am: ["am1"] },
};
});
afterEach(() => {
jest.restoreAllMocks();
useFetchGet.mockReset();
useFetchDelete.mockReset();
clear();
document.body.className = "";
});
describe("<PaginatedAlertList />", () => {
it("renders Placeholder while loading preview", () => {
useFetchGet.fetch.setMockedData({
response: null,
error: false,
isLoading: true,
isRetrying: false,
});
const tree = mount(
<PaginatedAlertList alertStore={alertStore} filters={["foo=bar"]} />
);
expect(tree.find("Placeholder")).toHaveLength(1);
});
it("renders LabelSetList with StaticLabel on mount", () => {
const tree = mount(
<PaginatedAlertList alertStore={alertStore} filters={["foo=bar"]} />
);
expect(tree.find("LabelSetList")).toHaveLength(1);
expect(tree.find("StaticLabel")).toHaveLength(3);
});
it("renders empty LabelSetList with empty response", () => {
useFetchGet.fetch.setMockedData({
response: EmptyAPIResponse(),
error: false,
isLoading: false,
isRetrying: false,
});
const tree = mount(
<PaginatedAlertList alertStore={alertStore} filters={["foo=bar"]} />
);
expect(tree.find("LabelSetList")).toHaveLength(1);
expect(tree.find("StaticLabel")).toHaveLength(0);
});
it("fetches affected alerts on mount", () => {
mount(<PaginatedAlertList alertStore={alertStore} filters={["foo=bar"]} />);
expect(useFetchGet).toHaveBeenCalled();
});
it("renders StaticLabel after fetch", () => {
const tree = mount(
<PaginatedAlertList
alertStore={alertStore}
filters={["foo=bar"]}
title="Affected alerts"
/>
);
expect(tree.text()).toMatch(/Affected alerts/);
expect(tree.find("StaticLabel")).toHaveLength(3);
});
it("handles empty grid response correctly", () => {
useFetchGet.fetch.setMockedData({
response: EmptyAPIResponse(),
error: false,
isLoading: false,
isRetrying: false,
});
const tree = mount(
<PaginatedAlertList alertStore={alertStore} filters={["foo=bar"]} />
);
expect(tree.text()).toMatch(/No alerts matched/);
});
it("renders FetchError on failed preview fetch", () => {
useFetchGet.fetch.setMockedData({
response: null,
error: "fake error",
isLoading: false,
isRetrying: false,
});
const tree = mount(
<PaginatedAlertList alertStore={alertStore} filters={["foo=bar"]} />
);
expect(tree.find("FetchError")).toHaveLength(1);
});
});

View File

@@ -4,63 +4,26 @@ import PropTypes from "prop-types";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowLeft } from "@fortawesome/free-solid-svg-icons/faArrowLeft";
import { faCheckCircle } from "@fortawesome/free-solid-svg-icons/faCheckCircle";
import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons/faExclamationCircle";
import { faSpinner } from "@fortawesome/free-solid-svg-icons/faSpinner";
import { AlertStore, FormatBackendURI, FormatAlertsQ } from "Stores/AlertStore";
import { AlertStore } from "Stores/AlertStore";
import { SilenceFormStore } from "Stores/SilenceFormStore";
import {
LabelSetList,
GroupListToUniqueLabelsList,
} from "Components/LabelSetList";
import { useFetchGet } from "Hooks/useFetchGet";
import { PaginatedAlertList } from "Components/PaginatedAlertList";
import { MatcherToFilter, AlertManagersToFilter } from "../Matchers";
const FetchError = ({ message }) => (
<div className="text-center">
<h2 className="display-2 text-danger">
<FontAwesomeIcon icon={faExclamationCircle} />
</h2>
<p className="lead text-muted">{message}</p>
</div>
);
FetchError.propTypes = {
message: PropTypes.node.isRequired,
};
const Placeholder = () => (
<div className="jumbotron bg-transparent">
<h1 className="display-5 text-placeholder text-center">
<FontAwesomeIcon icon={faSpinner} size="lg" spin />
</h1>
</div>
);
const SilencePreview = ({ alertStore, silenceFormStore }) => {
const filters = [
...silenceFormStore.data.matchers.map((m) => MatcherToFilter(m)),
AlertManagersToFilter(silenceFormStore.data.alertmanagers),
];
const { response, error, isLoading } = useFetchGet(
FormatBackendURI("alerts.json?") + FormatAlertsQ(filters)
);
return (
<React.Fragment>
<div className="mb-3">
{isLoading ? (
<Placeholder />
) : error ? (
<FetchError message={error} />
) : (
<LabelSetList
alertStore={alertStore}
labelsList={GroupListToUniqueLabelsList(
response.grids.length ? response.grids[0].alertGroups : []
)}
/>
)}
<PaginatedAlertList
alertStore={alertStore}
filters={filters}
title="Affected alerts"
/>
</div>
<div className="d-flex flex-row-reverse">
<button