Host Status Dropdown (#1556)

* #1372 created dropdown for status

* #1372 fixed default state for dropdown

* #1372 added help text and styling

* clean up

* fixed linting

* created changes log

* fixed e2e test

* created new header

* clean up

* added logic to edit and delete label using icons

* reworked selectedFilter to support status & label

* fixed multiple params in url

* comment clean up

* fixed tests

* linting fixes

* fixed height of status dropdown

* bug fix for selecting status 1st, label 2nd

* fixed e2e test

* minor style fix for side panel label scroll

* fixed label e2e test

* removed SQL editor for label selection

* removed edit and delete for platform labels

* fixed bugs loading hosts for every label click

* fixed linting

* fixed basic e2e test

* fixed observer basic e2e test

* modified changes file

* fixed bug with label replacement logic for url
This commit is contained in:
Martavis Parker 2021-08-06 15:09:49 -07:00 committed by GitHub
parent 93a10e6f94
commit 672db9e2a7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 339 additions and 225 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 B

View File

@ -0,0 +1,5 @@
- Added support for getting hosts by label and by status
- Created new dropdown on manage hosts page for status
- Removed status from sidebar
- Modified url to contain status, label or both
- Modified edit and delete buttons for labels, now icons

View File

@ -28,7 +28,7 @@ describe("Label flow", () => {
cy.findByText(/show all users/i).click();
cy.contains("button", /edit/i).click();
cy.get(".manage-hosts__label-block button").first().click();
// Label SQL not editable to test
@ -49,7 +49,7 @@ describe("Label flow", () => {
// Close success notification
cy.get(".flash-message__remove").click();
cy.findByRole("button", { name: /delete/i }).click();
cy.get(".manage-hosts__label-block button").last().click();
// Can't figure out how attach findByRole onto modal button
// Can't use findByText because delete button under modal

View File

@ -39,7 +39,7 @@ describe("SSO Sessions", () => {
cy.loginSSO();
cy.contains("All hosts");
cy.contains("Hosts");
});
it("Fails when IdP login disabled", () => {

View File

@ -28,7 +28,7 @@ describe("Basic tier - Admin user", () => {
cy.contains("button", /add new host/i).click();
// See the “Select a team for this new host” in the Add new host modal. This modal appears after the user selects the “Add new host” button
cy.get(".Select-control").click();
cy.get(".add-host-modal__team-dropdown-wrapper .Select-control").click();
cy.get(".add-host-modal__team-dropdown-wrapper").within(() => {
cy.findByText(/no team/i).should("exist");

View File

@ -55,7 +55,7 @@ describe("Basic tier - Observer user", () => {
cy.login("toni@organization.com", "user123#");
cy.visit("/hosts/manage");
cy.findByText("All hosts which have enrolled in Fleet").should("exist");
cy.findByText("Hosts").should("exist");
// Nav restrictions
cy.findByText(/settings/i).should("not.exist");

View File

@ -16,7 +16,7 @@ describe("Basic tier - Team observer/maintainer user", () => {
cy.visit("/");
// Ensure page is loaded
cy.contains("All hosts");
cy.contains("Hosts");
// On the Hosts page, they should…
@ -110,7 +110,7 @@ describe("Basic tier - Team observer/maintainer user", () => {
cy.visit("/");
// Ensure page is loaded and appropriate nav links are displayed
cy.contains("All hosts");
cy.contains("Hosts");
cy.get("nav").within(() => {
cy.findByText(/hosts/i).should("exist");
cy.findByText(/queries/i).should("exist");
@ -119,7 +119,7 @@ describe("Basic tier - Team observer/maintainer user", () => {
});
// Ensure page is loaded and appropriate nav links are displayed
cy.contains("All hosts");
cy.contains("Hosts");
cy.get("nav").within(() => {
cy.findByText(/hosts/i).should("exist");
cy.findByText(/queries/i).should("exist");

View File

@ -53,6 +53,7 @@ interface ITableContainerProps {
primarySelectActionButtonText?: string | ((targetIds: number[]) => string);
onPrimarySelectActionClick?: (selectedItemIds: number[]) => void;
secondarySelectActions?: IActionButtonProps[]; // TODO create table actions interface
customControl?: () => JSX.Element;
}
const baseClass = "table-container";
@ -91,6 +92,7 @@ const TableContainer = ({
primarySelectActionButtonText,
onPrimarySelectActionClick,
secondarySelectActions,
customControl,
}: ITableContainerProps): JSX.Element => {
const [searchQuery, setSearchQuery] = useState("");
const [sortHeader, setSortHeader] = useState(defaultSortHeader || "");
@ -227,6 +229,7 @@ const TableContainer = ({
</>
</Button>
)}
{customControl && customControl()}
{/* Render search bar only if not empty component */}
{searchable && !wideSearch && (
<div className={`${baseClass}__search-input`}>

View File

@ -30,7 +30,7 @@ const FlashMessage = ({
document.getElementById(`${klass}`).style.visibility = "hidden";
}, 4000); // Hides success alerts after 4 seconds
}
});
}, []);
if (!isVisible) {
return false;

View File

@ -40,13 +40,11 @@ class HostSidePanel extends Component {
onAddLabelClick,
onLabelClick,
selectedFilter,
statusLabels,
canAddNewLabel,
} = this.props;
const { labelFilter } = this.state;
const { onFilterLabels } = this;
const allHostLabels = filter(labels, { type: "all" });
const hostStatusLabels = filter(labels, { type: "status" });
const hostPlatformLabels = filter(labels, (label) => {
return label.type === "platform" && label.count > 0;
});
@ -58,7 +56,6 @@ class HostSidePanel extends Component {
return (
<SecondarySidePanelContainer className={`${baseClass}`}>
<h3>Status</h3>
<PanelGroup
groupItems={allHostLabels}
onLabelClick={onLabelClick}
@ -66,14 +63,6 @@ class HostSidePanel extends Component {
type="all-hosts"
/>
<PanelGroup
groupItems={hostStatusLabels}
onLabelClick={onLabelClick}
statusLabels={statusLabels}
selectedFilter={selectedFilter}
type="status"
/>
<h3>Operating Systems</h3>
<PanelGroup
groupItems={hostPlatformLabels}

View File

@ -1,7 +1,7 @@
.panel-group {
&__label {
max-height: 280px;
overflow-y: scroll;
overflow-y: auto;
position: relative;
&--scroll-labels {

View File

@ -1,6 +1,5 @@
import React, { PureComponent } from "react";
import PropTypes from "prop-types";
import AceEditor from "react-ace";
import { connect } from "react-redux";
import { push } from "react-router-redux";
@ -12,7 +11,6 @@ import Modal from "components/modals/Modal";
import QuerySidePanel from "components/side_panels/QuerySidePanel";
import TableContainer from "components/TableContainer";
import labelInterface from "interfaces/label";
import hostInterface from "interfaces/host";
import teamInterface from "interfaces/team";
import userInterface from "interfaces/user";
import osqueryTableInterface from "interfaces/osquery_table";
@ -24,16 +22,15 @@ import labelActions from "redux/nodes/entities/labels/actions";
import teamActions from "redux/nodes/entities/teams/actions";
import hostActions from "redux/nodes/entities/hosts/actions";
import entityGetter, { memoizedGetEntity } from "redux/utilities/entityGetter";
import {
getLabels,
getHosts,
} from "redux/nodes/components/ManageHostsPage/actions";
import { getLabels } from "redux/nodes/components/ManageHostsPage/actions";
import PATHS from "router/paths";
import deepDifference from "utilities/deep_difference";
import { find } from "lodash";
import hostClient from "services/entities/hosts";
import permissionUtils from "utilities/permissions";
import Dropdown from "components/forms/fields/Dropdown";
import {
defaultHiddenColumns,
generateVisibleTableColumns,
@ -45,10 +42,47 @@ import EmptyHosts from "./components/EmptyHosts";
import EditColumnsModal from "./components/EditColumnsModal/EditColumnsModal";
import TransferHostModal from "./components/TransferHostModal";
import EditColumnsIcon from "../../../../assets/images/icon-edit-columns-16x12@2x.png";
import PencilIcon from "../../../../assets/images/icon-pencil-14x14@2x.png";
import TrashIcon from "../../../../assets/images/icon-trash-14x14@2x.png";
const NEW_LABEL_HASH = "#new_label";
const EDIT_LABEL_HASH = "#edit_label";
const ALL_HOSTS_LABEL = "all-hosts";
const baseClass = "manage-hosts";
const LABEL_SLUG_PREFIX = "labels/";
const HOST_SELECT_STATUSES = [
{
disabled: false,
label: "All hosts",
value: ALL_HOSTS_LABEL,
helpText: "All hosts which have enrolled to Fleet.",
},
{
disabled: false,
label: "Online hosts",
value: "online",
helpText: "Hosts that have recently checked-in to Fleet.",
},
{
disabled: false,
label: "Offline hosts",
value: "offline",
helpText: "Hosts that have not checked-in to Fleet recently.",
},
{
disabled: false,
label: "New hosts",
value: "new",
helpText: "Hosts that have been enrolled to Fleet in the last 24 hours.",
},
{
disabled: false,
label: "MIA hosts",
value: "mia",
helpText: "Hosts that have not been seen by Fleet in more than 30 days.",
},
];
export class ManageHostsPage extends PureComponent {
static propTypes = {
@ -62,7 +96,7 @@ export class ManageHostsPage extends PureComponent {
labels: PropTypes.arrayOf(labelInterface),
loadingLabels: PropTypes.bool.isRequired,
enrollSecret: enrollSecretInterface,
selectedFilter: PropTypes.string,
selectedFilters: PropTypes.arrayOf(PropTypes.string),
selectedLabel: labelInterface,
selectedOsqueryTable: osqueryTableInterface,
statusLabels: statusLabelsInterface,
@ -129,14 +163,17 @@ export class ManageHostsPage extends PureComponent {
onAddLabelClick = (evt) => {
evt.preventDefault();
const { dispatch, selectedFilter } = this.props;
dispatch(push(`${PATHS.MANAGE_HOSTS}/${selectedFilter}${NEW_LABEL_HASH}`));
const { dispatch } = this.props;
dispatch(push(`${PATHS.MANAGE_HOSTS}${NEW_LABEL_HASH}`));
};
onEditLabelClick = (evt) => {
evt.preventDefault();
const { dispatch, selectedFilter } = this.props;
dispatch(push(`${PATHS.MANAGE_HOSTS}/${selectedFilter}${EDIT_LABEL_HASH}`));
const { getLabelSelected } = this;
const { dispatch } = this.props;
dispatch(
push(`${PATHS.MANAGE_HOSTS}/${getLabelSelected()}${EDIT_LABEL_HASH}`)
);
};
onEditColumnsClick = () => {
@ -160,13 +197,13 @@ export class ManageHostsPage extends PureComponent {
};
onCancelAddLabel = () => {
const { dispatch, selectedFilter } = this.props;
dispatch(push(`${PATHS.MANAGE_HOSTS}/${selectedFilter}`));
const { dispatch, selectedFilters } = this.props;
dispatch(push(`${PATHS.MANAGE_HOSTS}/${selectedFilters.join("/")}`));
};
onCancelEditLabel = () => {
const { dispatch, selectedFilter } = this.props;
dispatch(push(`${PATHS.MANAGE_HOSTS}/${selectedFilter}`));
const { dispatch, selectedFilters } = this.props;
dispatch(push(`${PATHS.MANAGE_HOSTS}/${selectedFilters.join("/")}`));
};
onAddHostClick = (evt) => {
@ -182,53 +219,41 @@ export class ManageHostsPage extends PureComponent {
// NOTE: this is called once on the initial rendering. The initial render of
// the TableContainer child component will call this handler.
onTableQueryChange = async (queryData) => {
const { selectedFilter, dispatch } = this.props;
const {
pageIndex,
pageSize,
searchQuery,
sortHeader,
sortDirection,
} = queryData;
onTableQueryChange = async ({
pageIndex,
pageSize,
searchQuery,
sortHeader,
sortDirection,
}) => {
const { retrieveHosts } = this;
const { selectedFilters } = this.props;
let sortBy = [];
if (sortHeader !== "") {
sortBy = [{ id: sortHeader, direction: sortDirection }];
}
// keep track as a local state to be used later
this.setState({
searchQuery,
isHostsLoading: true,
this.setState({ searchQuery });
retrieveHosts({
page: pageIndex,
perPage: pageSize,
selectedLabels: selectedFilters,
globalFilter: searchQuery,
sortBy,
});
try {
const { hosts } = await hostClient.loadAll({
page: pageIndex,
perPage: pageSize,
selectedLabel: selectedFilter,
globalFilter: searchQuery,
sortBy,
});
this.setState({ hosts });
} catch (error) {
console.log(error);
dispatch(
renderFlash("error", "Sorry, we could not retrieve your hosts.")
);
} finally {
this.setState({ isHostsLoading: false });
}
};
onEditLabel = (formData) => {
const { dispatch, selectedLabel, selectedFilter } = this.props;
const { getLabelSelected } = this;
const { dispatch, selectedLabel } = this.props;
const updateAttrs = deepDifference(formData, selectedLabel);
return dispatch(labelActions.update(selectedLabel, updateAttrs))
.then(() => {
dispatch(push(`${PATHS.MANAGE_HOSTS}/${selectedFilter}`));
dispatch(push(`${PATHS.MANAGE_HOSTS}/${getLabelSelected()}`));
dispatch(
renderFlash(
"success",
@ -243,18 +268,14 @@ export class ManageHostsPage extends PureComponent {
onLabelClick = (selectedLabel) => {
return (evt) => {
evt.preventDefault();
const { dispatch } = this.props;
const { MANAGE_HOSTS } = PATHS;
const { slug, type } = selectedLabel;
const nextLocation =
type === "all" ? MANAGE_HOSTS : `${MANAGE_HOSTS}/${slug}`;
dispatch(push(nextLocation));
const { handleLabelChange } = this;
handleLabelChange(selectedLabel);
};
};
onOsqueryTableSelect = (tableName) => {
const { dispatch } = this.props;
dispatch(selectOsqueryTable(tableName));
return false;
@ -294,8 +315,13 @@ export class ManageHostsPage extends PureComponent {
};
onTransferHostSubmit = (team) => {
const { toggleTransferHostModal, isAcceptableStatus } = this;
const { dispatch, selectedFilter, selectedLabel } = this.props;
const {
toggleTransferHostModal,
isAcceptableStatus,
getStatusSelected,
retrieveHosts,
} = this;
const { dispatch, selectedFilters, selectedLabel } = this.props;
const {
selectedHostIds,
isAllMatchingHostsSelected,
@ -308,8 +334,8 @@ export class ManageHostsPage extends PureComponent {
let status = "";
let labelId = null;
if (isAcceptableStatus(selectedFilter)) {
status = selectedFilter;
if (isAcceptableStatus(getStatusSelected())) {
status = getStatusSelected();
} else {
labelId = selectedLabel.id;
}
@ -329,7 +355,10 @@ export class ManageHostsPage extends PureComponent {
? `Hosts successfully removed from teams.`
: `Hosts successfully transferred to ${team.name}.`;
dispatch(renderFlash("success", successMessage));
dispatch(getHosts({ selectedLabel: selectedFilter, searchQuery }));
retrieveHosts({
selectedLabels: selectedFilters,
globalFilter: searchQuery,
});
})
.catch(() => {
dispatch(
@ -342,6 +371,33 @@ export class ManageHostsPage extends PureComponent {
this.setState({ isAllMatchingHostsSelected: false });
};
getLabelSelected = () => {
const { selectedFilters } = this.props;
return selectedFilters.find((f) => f.includes(LABEL_SLUG_PREFIX));
};
getStatusSelected = () => {
const { selectedFilters } = this.props;
return selectedFilters.find((f) => !f.includes(LABEL_SLUG_PREFIX));
};
retrieveHosts = async (options) => {
const { dispatch } = this.props;
this.setState({ isHostsLoading: true });
try {
const { hosts } = await hostClient.loadAll(options);
this.setState({ hosts });
} catch (error) {
console.log(error);
dispatch(
renderFlash("error", "Sorry, we could not retrieve your hosts.")
);
} finally {
this.setState({ isHostsLoading: false });
}
};
isAcceptableStatus = (filter) => {
return (
filter === "new" ||
@ -374,7 +430,6 @@ export class ManageHostsPage extends PureComponent {
};
toggleAllMatchingHosts = (shouldSelect = undefined) => {
// shouldSelect?: boolean
const { isAllMatchingHostsSelected } = this.state;
if (shouldSelect !== undefined) {
@ -386,6 +441,51 @@ export class ManageHostsPage extends PureComponent {
}
};
handleLabelChange = ({ slug, type }) => {
const { dispatch, selectedFilters } = this.props;
const { MANAGE_HOSTS } = PATHS;
const isAllHosts = slug === ALL_HOSTS_LABEL;
const newFilters = [...selectedFilters];
if (!isAllHosts) {
// always remove "all-hosts" from the filters first because we don't want
// something like ["label/8", "all-hosts"]
const allIndex = newFilters.findIndex((f) => f.includes(ALL_HOSTS_LABEL));
allIndex > -1 && newFilters.splice(allIndex, 1);
// replace slug for new params
let index;
if (slug.includes(LABEL_SLUG_PREFIX)) {
index = newFilters.findIndex((f) => f.includes(LABEL_SLUG_PREFIX));
} else {
index = newFilters.findIndex((f) => !f.includes(LABEL_SLUG_PREFIX));
}
if (index > -1) {
newFilters.splice(index, 1, slug);
} else {
newFilters.push(slug);
}
}
const nextLocation = isAllHosts
? MANAGE_HOSTS
: `${MANAGE_HOSTS}/${newFilters.join("/")}`;
dispatch(push(nextLocation));
};
handleStatusDropdownChange = (statusName) => {
const { handleLabelChange } = this;
const { labels } = this.props;
// we want the full label object
const isAll = statusName === ALL_HOSTS_LABEL;
const selected = isAll
? find(labels, { type: "all" })
: find(labels, { id: statusName });
handleLabelChange(selected);
};
renderEditColumnsModal = () => {
const { config, currentUser } = this.props;
const { showEditColumnsModal, hiddenColumns } = this.state;
@ -478,89 +578,54 @@ export class ManageHostsPage extends PureComponent {
);
};
renderDeleteButton = () => {
const { toggleDeleteLabelModal, onEditLabelClick } = this;
const {
selectedLabel: { type },
} = this.props;
if (type !== "custom") {
return false;
}
renderHeaderLabelBlock = ({
description,
display_text: displayText,
type,
}) => {
const { onEditLabelClick, toggleDeleteLabelModal } = this;
return (
<div className={`${baseClass}__label-actions`}>
<Button onClick={onEditLabelClick} variant="inverse">
Edit
</Button>
<Button onClick={toggleDeleteLabelModal} variant="inverse">
Delete
</Button>
<div className={`${baseClass}__label-block`}>
<div className="title">
<span>{displayText}</span>
{type !== "platform" && (
<>
<Button onClick={onEditLabelClick} variant={"text-icon"}>
<img src={PencilIcon} alt="Edit label" />
</Button>
<Button onClick={toggleDeleteLabelModal} variant={"text-icon"}>
<img src={TrashIcon} alt="Delete label" />
</Button>
</>
)}
</div>
<div className="description">
<span>{description}</span>
</div>
</div>
);
};
renderQuery = () => {
const { selectedLabel } = this.props;
const {
slug,
label_type: labelType,
label_membership_type: membershipType,
query,
} = selectedLabel;
if (membershipType === "manual" && labelType !== "builtin") {
return (
<h4 title="Manage manual labels with fleetctl">Manually managed</h4>
);
}
if (!query || slug === "all-hosts") {
return false;
}
return (
<AceEditor
editorProps={{ $blockScrolling: Infinity }}
mode="fleet"
minLines={1}
maxLines={20}
name="label-header"
readOnly
setOptions={{ wrap: true }}
showGutter={false}
showPrintMargin={false}
theme="fleet"
value={query}
width="100%"
fontSize={14}
/>
);
};
renderHeader = () => {
const { renderDeleteButton } = this;
const { renderHeaderLabelBlock } = this;
const { isAddLabel, selectedLabel } = this.props;
if (!selectedLabel || isAddLabel) {
return false;
}
const { description, display_text: displayText } = selectedLabel;
const defaultDescription = "No description available.";
const { type } = selectedLabel;
return (
<div className={`${baseClass}__header`}>
<div className={`${baseClass}__text`}>
<h1 className={`${baseClass}__title`}>
<span>{displayText}</span>
<span>Hosts</span>
</h1>
<div className={`${baseClass}__description`}>
<p>{description || <em>{defaultDescription}</em>}</p>
</div>
{type !== "all" &&
type !== "status" &&
renderHeaderLabelBlock(selectedLabel)}
</div>
{renderDeleteButton()}
</div>
);
};
@ -611,12 +676,17 @@ export class ManageHostsPage extends PureComponent {
const {
isAddLabel,
labels,
selectedFilter,
selectedOsqueryTable,
statusLabels,
canAddNewLabels,
} = this.props;
const { onAddLabelClick, onLabelClick, onOsqueryTableSelect } = this;
const {
onAddLabelClick,
onLabelClick,
onOsqueryTableSelect,
getLabelSelected,
getStatusSelected,
} = this;
if (isAddLabel) {
SidePanel = (
@ -633,7 +703,7 @@ export class ManageHostsPage extends PureComponent {
labels={labels}
onAddLabelClick={onAddLabelClick}
onLabelClick={onLabelClick}
selectedFilter={selectedFilter}
selectedFilter={getLabelSelected() || getStatusSelected()}
statusLabels={statusLabels}
canAddNewLabel={canAddNewLabels}
/>
@ -643,14 +713,22 @@ export class ManageHostsPage extends PureComponent {
return SidePanel;
};
renderStatusDropdown = () => {
const { handleStatusDropdownChange, getStatusSelected } = this;
return (
<Dropdown
value={getStatusSelected() || ALL_HOSTS_LABEL}
className={`${baseClass}__status_dropdown`}
options={HOST_SELECT_STATUSES}
searchable={false}
onChange={handleStatusDropdownChange}
/>
);
};
renderTable = () => {
const {
config,
currentUser,
selectedFilter,
selectedLabel,
loadingHosts,
} = this.props;
const { config, currentUser, selectedFilters, selectedLabel } = this.props;
const {
hiddenColumns,
isAllMatchingHostsSelected,
@ -662,14 +740,16 @@ export class ManageHostsPage extends PureComponent {
onEditColumnsClick,
onTransferToTeamClick,
toggleAllMatchingHosts,
renderStatusDropdown,
getStatusSelected,
} = this;
// The data has not been fetched yet.
if (selectedFilter === undefined || selectedLabel === undefined)
if (selectedFilters.length === 0 || selectedLabel === undefined)
return null;
// Hosts have not been set up for this instance yet.
if (selectedFilter === "all-hosts" && selectedLabel.count === 0) {
if (getStatusSelected() === ALL_HOSTS_LABEL && selectedLabel.count === 0) {
return <NoHosts />;
}
@ -688,7 +768,7 @@ export class ManageHostsPage extends PureComponent {
actionButtonText={"Edit columns"}
actionButtonIcon={EditColumnsIcon}
actionButtonVariant={"text-icon"}
additionalQueries={JSON.stringify([selectedFilter])}
additionalQueries={JSON.stringify(selectedFilters)}
inputPlaceHolder={"Search hostname, UUID, serial number, or IPv4"}
onActionButtonClick={onEditColumnsClick}
onPrimarySelectActionClick={onTransferToTeamClick}
@ -700,6 +780,7 @@ export class ManageHostsPage extends PureComponent {
isAllPagesSelected={isAllMatchingHostsSelected}
toggleAllPagesSelected={toggleAllMatchingHosts}
searchable
customControl={renderStatusDropdown}
/>
);
};
@ -711,7 +792,6 @@ export class ManageHostsPage extends PureComponent {
renderSidePanel,
renderAddHostModal,
renderDeleteLabelModal,
renderQuery,
renderTable,
renderEditColumnsModal,
renderTransferHostModal,
@ -721,7 +801,6 @@ export class ManageHostsPage extends PureComponent {
isAddLabel,
isEditLabel,
loadingLabels,
selectedLabel,
canAddNewHosts,
} = this.props;
@ -741,7 +820,6 @@ export class ManageHostsPage extends PureComponent {
</Button>
) : null}
</div>
{selectedLabel && renderQuery()}
{renderTable()}
</div>
)}
@ -757,18 +835,30 @@ export class ManageHostsPage extends PureComponent {
const mapStateToProps = (state, { location, params }) => {
const { active_label: activeLabel, label_id: labelID } = params;
const activeLabelSlug = activeLabel || "all-hosts";
const selectedFilter = labelID ? `labels/${labelID}` : activeLabelSlug;
const selectedFilters = [];
labelID && selectedFilters.push(`${LABEL_SLUG_PREFIX}${labelID}`);
activeLabel && selectedFilters.push(activeLabel);
// "all-hosts" should always be alone
!labelID && !activeLabel && selectedFilters.push(ALL_HOSTS_LABEL);
const { status_labels: statusLabels } = state.components.ManageHostsPage;
const labelEntities = entityGetter(state).get("labels");
const { entities: labels } = labelEntities;
const isAddLabel = location.hash === NEW_LABEL_HASH;
const isEditLabel = location.hash === EDIT_LABEL_HASH;
// eqivalent to old way => const selectedFilter = labelID ? `labels/${labelID}` : activeLabelSlug;
const slugToFind =
(selectedFilters.length > 0 &&
selectedFilters.find((f) => f.includes(LABEL_SLUG_PREFIX))) ||
selectedFilters[0];
const selectedLabel = labelEntities.findBy(
{ slug: selectedFilter },
{ slug: slugToFind },
{ ignoreCase: true }
);
const isAddLabel = location.hash === NEW_LABEL_HASH;
const isEditLabel = location.hash === EDIT_LABEL_HASH;
const { selectedOsqueryTable } = state.components.QueryPages;
const { errors: labelErrors, loading: loadingLabels } = state.entities.labels;
const enrollSecret = state.app.enrollSecret;
@ -789,7 +879,7 @@ const mapStateToProps = (state, { location, params }) => {
const teams = memoizedGetEntity(state.entities.teams.data);
return {
selectedFilter,
selectedFilters,
isAddLabel,
isEditLabel,
labelErrors,

View File

@ -126,7 +126,12 @@ describe("ManageHostsPage - component", () => {
describe("side panels", () => {
it("renders a HostSidePanel when not adding a new label", () => {
const page = shallow(<ManageHostsPage {...props} />);
const pageProps = {
...props,
selectedFilters: [],
};
const page = shallow(<ManageHostsPage {...pageProps} />);
expect(page.find("HostSidePanel").length).toEqual(1);
});
@ -194,41 +199,23 @@ describe("ManageHostsPage - component", () => {
});
});
it("Renders the default description if the selected label does not have a description", () => {
const defaultDescription = "No description available.";
const noDescriptionLabel = { ...allHostsLabel, description: undefined };
const pageProps = {
...props,
selectedLabel: noDescriptionLabel,
};
const Page = shallow(<ManageHostsPage {...pageProps} />);
expect(Page.find(".manage-hosts__header").text()).toContain(
defaultDescription
);
});
it("Renders the label description if the selected label has a description", () => {
const defaultDescription = "No description available.";
const labelDescription = "This is the label description";
const noDescriptionLabel = {
...allHostsLabel,
const descriptionLabel = {
...customLabel,
description: labelDescription,
};
const pageProps = {
...props,
selectedLabel: noDescriptionLabel,
selectedLabel: descriptionLabel,
selectedFilters: [],
};
const Page = shallow(<ManageHostsPage {...pageProps} />);
expect(Page.find(".manage-hosts__header").text()).toContain(
labelDescription
);
expect(Page.find(".manage-hosts__header").text()).not.toContain(
defaultDescription
);
expect(
Page.find(".manage-hosts__label-block .description span").text()
).toContain(labelDescription);
});
});
@ -244,7 +231,7 @@ describe("ManageHostsPage - component", () => {
it("renders the Edit button when a custom label is selected", () => {
const Page = mount(component);
const EditButton = Page.find(".manage-hosts__label-actions")
const EditButton = Page.find(".manage-hosts__label-block")
.find("Button")
.first();
@ -288,7 +275,7 @@ describe("ManageHostsPage - component", () => {
});
const page = mount(component);
const deleteBtn = page
.find(".manage-hosts__label-actions")
.find(".manage-hosts__label-block")
.find("Button")
.last();

View File

@ -1,7 +1,7 @@
.manage-hosts {
.header-wrap {
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
margin-bottom: $pad-medium;
}
@ -48,22 +48,30 @@
}
}
&__description {
margin: 0 0 $pad-medium;
padding-top: $pad-xsmall;
&__label-block {
margin-top: $pad-medium;
h2 {
text-transform: uppercase;
color: $core-fleet-black;
font-weight: $regular;
font-size: $small;
.title {
span {
font-size: $x-small;
font-weight: $bold;
}
button {
margin-left: 12px;
padding: 0;
height: auto;
img {
margin: 0;
}
}
}
p {
color: $core-dark-blue-grey;
margin: 0;
font-size: $x-small;
font-style: italic;
.description {
span {
vertical-align: text-top;
font-size: $x-small;
opacity: 0.5;
}
}
}
@ -99,4 +107,20 @@
margin-right: $pad-medium;
}
}
.form-field--dropdown {
margin: 0;
}
&__status_dropdown {
width: 159px;
.Select-menu-outer {
width: 364px;
max-height: 310px;
.Select-menu {
max-height: none;
}
}
}
}

View File

@ -91,6 +91,14 @@ const routes = (
component={ManageHostsPage}
/>
<Route path="manage/:active_label" component={ManageHostsPage} />
<Route
path="manage/labels/:label_id/:active_label"
component={ManageHostsPage}
/>
<Route
path="manage/:active_label/labels/:label_id"
component={ManageHostsPage}
/>
<Route path=":host_id" component={HostDetailsPage} />
</Route>
<Route component={AuthGlobalAdminMaintainerRoutes}>

View File

@ -10,7 +10,7 @@ interface ISortOption {
interface IHostLoadOptions {
page: number;
perPage: number;
selectedLabel: string;
selectedLabels: string[];
globalFilter: string;
sortBy: ISortOption[];
}
@ -38,7 +38,7 @@ export default {
const { HOSTS, LABEL_HOSTS } = endpoints;
const page = options?.page || 0;
const perPage = options?.perPage || 100;
const selectedLabel = options?.selectedLabel || "";
const selectedLabels = options?.selectedLabels || [];
const globalFilter = options?.globalFilter || "";
const sortBy = options?.sortBy || [];
@ -60,22 +60,30 @@ export default {
let path = "";
const labelPrefix = "labels/";
if (selectedLabel.startsWith(labelPrefix)) {
const lid = selectedLabel.substr(labelPrefix.length);
// Handle multiple filters
const label = selectedLabels.find((f) => f.includes(labelPrefix));
const status = selectedLabels.find((f) => !f.includes(labelPrefix));
const isValidStatus =
status === "new" ||
status === "online" ||
status === "offline" ||
status === "mia";
if (label) {
const lid = label.substr(labelPrefix.length);
path = `${LABEL_HOSTS(
parseInt(lid, 10)
)}?${pagination}${searchQuery}${orderKeyParam}${orderDirection}`;
} else {
let selectedFilter = "";
if (
selectedLabel === "new" ||
selectedLabel === "online" ||
selectedLabel === "offline" ||
selectedLabel === "mia"
) {
selectedFilter = `&status=${selectedLabel}`;
// connect status if applicable
if (status && isValidStatus) {
path += `&status=${status}`;
}
path = `${HOSTS}?${pagination}${selectedFilter}${searchQuery}${orderKeyParam}${orderDirection}`;
} else if (status && isValidStatus) {
path = `${HOSTS}?${pagination}&status=${status}${searchQuery}${orderKeyParam}${orderDirection}`;
} else {
path = `${HOSTS}?${pagination}${searchQuery}${orderKeyParam}${orderDirection}`;
}
return sendRequest("GET", path);