mirror of
https://github.com/empayre/fleet.git
synced 2024-11-06 08:55:24 +00:00
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:
parent
93a10e6f94
commit
672db9e2a7
BIN
assets/images/icon-filter-32x32@2x.png
Normal file
BIN
assets/images/icon-filter-32x32@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 310 B |
5
changes/1372-hosts-status-dropdown
Normal file
5
changes/1372-hosts-status-dropdown
Normal 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
|
@ -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
|
||||
|
@ -39,7 +39,7 @@ describe("SSO Sessions", () => {
|
||||
|
||||
cy.loginSSO();
|
||||
|
||||
cy.contains("All hosts");
|
||||
cy.contains("Hosts");
|
||||
});
|
||||
|
||||
it("Fails when IdP login disabled", () => {
|
||||
|
@ -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");
|
||||
|
@ -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");
|
||||
|
@ -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");
|
||||
|
@ -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`}>
|
||||
|
@ -30,7 +30,7 @@ const FlashMessage = ({
|
||||
document.getElementById(`${klass}`).style.visibility = "hidden";
|
||||
}, 4000); // Hides success alerts after 4 seconds
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!isVisible) {
|
||||
return false;
|
||||
|
@ -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}
|
||||
|
@ -1,7 +1,7 @@
|
||||
.panel-group {
|
||||
&__label {
|
||||
max-height: 280px;
|
||||
overflow-y: scroll;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
|
||||
&--scroll-labels {
|
||||
|
@ -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,
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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}>
|
||||
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user