mirror of
https://github.com/empayre/fleet.git
synced 2024-11-06 08:55:24 +00:00
Manage Host Page: Export hosts as CSV (#4917)
This commit is contained in:
parent
0bca26be03
commit
53ca15e93b
BIN
assets/images/icon-error-white-16x16@2x.png
Normal file
BIN
assets/images/icon-error-white-16x16@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 453 B |
1
changes/issue-2814-export-hosts-as-csv
Normal file
1
changes/issue-2814-export-hosts-as-csv
Normal file
@ -0,0 +1 @@
|
||||
* Add ability to export host as CSV from the UI
|
@ -1,4 +1,5 @@
|
||||
import * as path from "path";
|
||||
import { format } from "date-fns";
|
||||
|
||||
let hostname = "";
|
||||
|
||||
@ -28,6 +29,7 @@ describe("Hosts flow", () => {
|
||||
cy.visit("/hosts/manage");
|
||||
|
||||
cy.getAttached(".manage-hosts").within(() => {
|
||||
cy.getAttached(".manage-hosts__export-btn").click();
|
||||
cy.contains("button", /add hosts/i).click();
|
||||
});
|
||||
cy.getAttached(".react-tabs").within(() => {
|
||||
@ -45,6 +47,11 @@ describe("Hosts flow", () => {
|
||||
// before each test run (seems to be related to issues with Cypress trashAssetsBeforeRun)
|
||||
if (Cypress.platform !== "win32") {
|
||||
// windows has issues with downloads location
|
||||
const formattedTime = format(new Date(), "yyyy-MM-dd");
|
||||
const filename = `Hosts ${formattedTime}.csv`;
|
||||
cy.readFile(path.join(Cypress.config("downloadsFolder"), filename), {
|
||||
timeout: 5000,
|
||||
});
|
||||
cy.readFile(
|
||||
path.join(Cypress.config("downloadsFolder"), "secret.txt"),
|
||||
{
|
||||
|
@ -8,6 +8,7 @@ import Button from "components/buttons/Button";
|
||||
|
||||
import CloseIcon from "../../../assets/images/icon-close-white-16x16@2x.png";
|
||||
import CloseIconBlack from "../../../assets/images/icon-close-fleet-black-16x16@2x.png";
|
||||
import ErrorIcon from "../../../assets/images/icon-error-white-16x16@2x.png";
|
||||
|
||||
const baseClass = "flash-message";
|
||||
|
||||
@ -60,13 +61,15 @@ const FlashMessage = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const alertIcon =
|
||||
alertType === "success" ? "success-check" : "warning-filled";
|
||||
|
||||
return (
|
||||
<div className={klass} id={klass}>
|
||||
<div className={`${baseClass}__content`}>
|
||||
<FleetIcon name={alertIcon} /> <span>{message}</span>
|
||||
{alertType === "success" ? (
|
||||
<FleetIcon name="success-check" />
|
||||
) : (
|
||||
<img alt="error icon" src={ErrorIcon} />
|
||||
)}
|
||||
<span>{message}</span>
|
||||
{onUndoActionClick && undoAction && (
|
||||
<Button
|
||||
className={`${baseClass}__undo`}
|
||||
|
@ -76,6 +76,11 @@
|
||||
.fleeticon {
|
||||
font-size: $small;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&__undo {
|
||||
|
@ -17,6 +17,7 @@ export default {
|
||||
HOSTS: "/v1/fleet/hosts",
|
||||
HOSTS_COUNT: "/v1/fleet/hosts/count",
|
||||
HOSTS_DELETE: "/v1/fleet/hosts/delete",
|
||||
HOSTS_REPORT: "/v1/fleet/hosts/report",
|
||||
HOSTS_TRANSFER: "/v1/fleet/hosts/transfer",
|
||||
HOSTS_TRANSFER_BY_FILTER: "/v1/fleet/hosts/transfer/filter",
|
||||
INVITES: "/v1/fleet/invites",
|
||||
|
@ -4,6 +4,8 @@ import { InjectedRouter, Params } from "react-router/lib/Router";
|
||||
import { RouteProps } from "react-router/lib/Route";
|
||||
import { find, isEmpty, isEqual, omit } from "lodash";
|
||||
import ReactTooltip from "react-tooltip";
|
||||
import { format } from "date-fns";
|
||||
import FileSaver from "file-saver";
|
||||
|
||||
import enrollSecretsAPI from "services/entities/enroll_secret";
|
||||
import labelsAPI from "services/entities/labels";
|
||||
@ -89,6 +91,7 @@ import TrashIcon from "../../../../assets/images/icon-trash-14x14@2x.png";
|
||||
import CloseIcon from "../../../../assets/images/icon-close-vibrant-blue-16x16@2x.png";
|
||||
import CloseIconBlack from "../../../../assets/images/icon-close-fleet-black-16x16@2x.png";
|
||||
import PolicyIcon from "../../../../assets/images/icon-policy-fleet-black-12x12@2x.png";
|
||||
import DownloadIcon from "../../../../assets/images/icon-download-12x12@2x.png";
|
||||
|
||||
interface IManageHostsProps {
|
||||
route: RouteProps;
|
||||
@ -117,6 +120,7 @@ interface ITableQueryProps {
|
||||
sortDirection: string;
|
||||
}
|
||||
|
||||
const CSV_HOSTS_TITLE = "Hosts";
|
||||
const baseClass = "manage-hosts";
|
||||
|
||||
const ManageHostsPage = ({
|
||||
@ -1348,6 +1352,74 @@ const ManageHostsPage = ({
|
||||
);
|
||||
};
|
||||
|
||||
const onExportHostsResults = async (
|
||||
evt: React.MouseEvent<HTMLButtonElement>
|
||||
) => {
|
||||
evt.preventDefault();
|
||||
|
||||
let options = {
|
||||
selectedLabels: selectedFilters,
|
||||
globalFilter: searchQuery,
|
||||
sortBy,
|
||||
teamId: currentTeam?.id,
|
||||
policyId,
|
||||
policyResponse,
|
||||
softwareId,
|
||||
};
|
||||
|
||||
options = {
|
||||
...options,
|
||||
teamId: getValidatedTeamId(
|
||||
availableTeams || [],
|
||||
options.teamId as number,
|
||||
currentUser,
|
||||
isOnGlobalTeam as boolean
|
||||
),
|
||||
};
|
||||
|
||||
if (queryParams.team_id) {
|
||||
options.teamId = queryParams.team_id;
|
||||
}
|
||||
|
||||
try {
|
||||
const exportHostResults = await hostsAPI.exportHosts(options);
|
||||
|
||||
const formattedTime = format(new Date(), "yyyy-MM-dd");
|
||||
const filename = `${CSV_HOSTS_TITLE} ${formattedTime}.csv`;
|
||||
const file = new global.window.File([exportHostResults], filename, {
|
||||
type: "text/csv",
|
||||
});
|
||||
|
||||
FileSaver.saveAs(file);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
renderFlash("error", "Could not export hosts. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const renderHostCount = useCallback(() => {
|
||||
const count = filteredHostCount;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${baseClass}__count ${
|
||||
isHostCountLoading ? "count-loading" : ""
|
||||
}`}
|
||||
>
|
||||
<span>{`${count} host${count === 1 ? "" : "s"}`}</span>
|
||||
<Button
|
||||
className={`${baseClass}__export-btn`}
|
||||
onClick={onExportHostsResults}
|
||||
variant="text-link"
|
||||
>
|
||||
<>
|
||||
Export hosts <img alt="" src={DownloadIcon} />
|
||||
</>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}, [isHostCountLoading, filteredHostCount]);
|
||||
|
||||
const renderActiveFilterBlock = () => {
|
||||
const showSelectedLabel =
|
||||
selectedLabel &&
|
||||
@ -1528,7 +1600,7 @@ const ManageHostsPage = ({
|
||||
showMarkAllPages
|
||||
isAllPagesSelected={isAllMatchingHostsSelected}
|
||||
searchable
|
||||
filteredCount={filteredHostCount}
|
||||
renderCount={renderHostCount}
|
||||
searchToolTipText={
|
||||
"Search hosts by hostname, UUID, machine serial or IP address"
|
||||
}
|
||||
|
@ -259,4 +259,16 @@
|
||||
.total-issues-count {
|
||||
margin-left: $pad-small;
|
||||
}
|
||||
|
||||
&__export-btn {
|
||||
padding-left: $pad-medium;
|
||||
|
||||
img {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
margin-left: 8px;
|
||||
position: relative;
|
||||
top: -2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -50,6 +50,74 @@ export default {
|
||||
},
|
||||
});
|
||||
},
|
||||
exportHosts: (options: ILoadHostsOptions | undefined) => {
|
||||
const { HOSTS_REPORT, LABEL_HOSTS } = endpoints;
|
||||
const selectedLabels = options?.selectedLabels || [];
|
||||
const globalFilter = options?.globalFilter || "";
|
||||
const sortBy = options?.sortBy || [];
|
||||
const teamId = options?.teamId || null;
|
||||
const policyId = options?.policyId || null;
|
||||
const policyResponse = options?.policyResponse || null;
|
||||
const softwareId = options?.softwareId || null;
|
||||
|
||||
let orderKeyParam = "";
|
||||
let orderDirection = "";
|
||||
if (sortBy.length !== 0) {
|
||||
const sortItem = sortBy[0];
|
||||
orderKeyParam += `&order_key=${sortItem.key}`;
|
||||
orderDirection = `&order_direction=${sortItem.direction}`;
|
||||
}
|
||||
|
||||
let searchQuery = "";
|
||||
if (globalFilter !== "") {
|
||||
searchQuery = `&query=${globalFilter}`;
|
||||
}
|
||||
|
||||
let path = "";
|
||||
const labelPrefix = "labels/";
|
||||
|
||||
// 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)
|
||||
)}?${searchQuery}${orderKeyParam}${orderDirection}`;
|
||||
|
||||
// connect status if applicable
|
||||
if (status && isValidStatus) {
|
||||
path += `&status=${status}`;
|
||||
}
|
||||
} else if (status && isValidStatus) {
|
||||
path = `${HOSTS_REPORT}?&status=${status}${searchQuery}${orderKeyParam}${orderDirection}`;
|
||||
} else {
|
||||
path = `${HOSTS_REPORT}?${searchQuery}${orderKeyParam}${orderDirection}`;
|
||||
}
|
||||
|
||||
if (teamId) {
|
||||
path += `&team_id=${teamId}`;
|
||||
}
|
||||
|
||||
if (!label && policyId) {
|
||||
path += `&policy_id=${policyId}`;
|
||||
path += `&policy_response=${policyResponse || "passing"}`; // TODO: confirm whether there should be a default if there is an id but no response sepcified
|
||||
}
|
||||
// TODO: consider how to check for mutually exclusive scenarios with label, policy and software
|
||||
if (!label && !policyId && softwareId) {
|
||||
path += `&software_id=${softwareId}`;
|
||||
}
|
||||
|
||||
path += "&format=csv";
|
||||
|
||||
return sendRequest("GET", path);
|
||||
},
|
||||
loadHosts: (options: ILoadHostsOptions | undefined) => {
|
||||
const { HOSTS, LABEL_HOSTS } = endpoints;
|
||||
const page = options?.page || 0;
|
||||
|
Loading…
Reference in New Issue
Block a user