From 6cac6ed80a96622afd155c32cd51fc5db9a4f617 Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Tue, 29 Aug 2023 08:51:37 -0400 Subject: [PATCH] Fleet UI: Live query UI and export results tables include all columns returned (#13428) --- changes/12476-ui-export-shows-all-columns | 1 + .../hosts/ManageHostsPage/ManageHostsPage.tsx | 1 - .../components/QueryResults/QueryResults.tsx | 37 ++++----- .../components/QueryResults/QueryResults.tsx | 78 ++++++++----------- .../QueryResults/QueryResultsTableConfig.tsx | 30 +++---- .../queries/QueryPage/screens/RunQuery.tsx | 1 + .../convert_to_csv/convert_to_csv.tests.ts | 2 +- frontend/utilities/convert_to_csv/index.ts | 28 +++++-- frontend/utilities/generate_csv/index.ts | 63 +++++++++++++++ 9 files changed, 152 insertions(+), 89 deletions(-) create mode 100644 changes/12476-ui-export-shows-all-columns create mode 100644 frontend/utilities/generate_csv/index.ts diff --git a/changes/12476-ui-export-shows-all-columns b/changes/12476-ui-export-shows-all-columns new file mode 100644 index 0000000000..35a558c335 --- /dev/null +++ b/changes/12476-ui-export-shows-all-columns @@ -0,0 +1 @@ +- Bug fix: Live query UI and Export data tables show all returned columns diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx index b64349713c..ab6a3b4deb 100644 --- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx +++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx @@ -1285,7 +1285,6 @@ const ManageHostsPage = ({ { [`${baseClass}__status-dropdown-sandbox`]: isSandboxMode } ); - console.log("status", status); return (
void; onStopQuery: (evt: React.MouseEvent) => void; setSelectedTargets: (value: ITarget[]) => void; @@ -32,41 +35,16 @@ interface IQueryResultsProps { } const baseClass = "query-results"; -const CSV_QUERY_TITLE = "Query Results"; +const CSV_TITLE = "New Query"; const NAV_TITLES = { RESULTS: "Results", ERRORS: "Errors", }; -const reorderCSVFields = (fields: string[]) => { - const result = fields.filter((field) => field !== "host_display_name"); - result.unshift("host_display_name"); - - return result; -}; - -const generateExportCSVFile = (rows: Row[], filename: string) => { - return new global.window.File( - [ - convertToCSV( - rows.map((r) => r.original), - reorderCSVFields - ), - ], - filename, - { - type: "text/csv", - } - ); -}; - -const generateExportFilename = (descriptor: string) => { - return `${descriptor} (${format(new Date(), "MM-dd-yy hh-mm-ss")}).csv`; -}; - const QueryResults = ({ campaign, isQueryFinished, + queryName, onRunQuery, onStopQuery, setSelectedTargets, @@ -82,10 +60,8 @@ const QueryResults = ({ const [showQueryModal, setShowQueryModal] = useState(false); const [filteredResults, setFilteredResults] = useState([]); const [filteredErrors, setFilteredErrors] = useState([]); - const [tableHeaders, setTableHeaders] = useState([]); - const [errorTableHeaders, setErrorTableHeaders] = useState( - [] - ); + const [tableHeaders, setTableHeaders] = useState([]); + const [errorTableHeaders, setErrorTableHeaders] = useState([]); const [queryResultsForTableRender, setQueryResultsForTableRender] = useState( queryResults ); @@ -102,32 +78,43 @@ const QueryResults = ({ { maxWait: 2000 } ); + // This is throwing an error not to use hook within a useEffect useEffect(() => { debounceQueryResults(queryResults); }, [queryResults, debounceQueryResults]); - // set tableHeaders when initial results come in - // instead of memoizing tableHeaders, since we know the conditions exactly under which we want to - // set these useEffect(() => { - if (tableHeaders?.length === 0 && !!queryResults?.length) { - setTableHeaders(generateResultsTableHeaders(queryResults)); + if (queryResults && queryResults.length > 0) { + const generatedTableHeaders = generateResultsTableHeaders(queryResults); + // Update tableHeaders if new headers are found + if (generatedTableHeaders !== tableHeaders) { + setTableHeaders(generatedTableHeaders); + } } - }, [tableHeaders, queryResults]); + }, [queryResults]); // Cannot use tableHeaders as it will cause infinite loop with setTableHeaders useEffect(() => { if (errorTableHeaders?.length === 0 && !!errors?.length) { setErrorTableHeaders(generateResultsTableHeaders(errors)); + + if (errorTableHeaders && errorTableHeaders.length > 0) { + const generatedErrorTableHeaders = generateResultsTableHeaders(errors); + + // Update errorTableHeaders if new headers are found + if (generatedErrorTableHeaders !== tableHeaders) { + setErrorTableHeaders(generatedErrorTableHeaders); + } + } } - }, [errorTableHeaders, errors]); + }, [errors]); // Cannot use errorTableHeaders as it will cause infinite loop with setErrorTableHeaders const onExportQueryResults = (evt: React.MouseEvent) => { evt.preventDefault(); - FileSaver.saveAs( - generateExportCSVFile( + generateCSVQueryResults( filteredResults, - generateExportFilename(CSV_QUERY_TITLE) + generateCSVFilename(`${queryName || CSV_TITLE} - Results`), + tableHeaders ) ); }; @@ -136,9 +123,10 @@ const QueryResults = ({ evt.preventDefault(); FileSaver.saveAs( - generateExportCSVFile( + generateCSVQueryResults( filteredErrors, - generateExportFilename(`${CSV_QUERY_TITLE} Errors`) + generateCSVFilename(`${queryName || CSV_TITLE} - Errors`), + errorTableHeaders ) ); }; diff --git a/frontend/pages/queries/QueryPage/components/QueryResults/QueryResultsTableConfig.tsx b/frontend/pages/queries/QueryPage/components/QueryResults/QueryResultsTableConfig.tsx index 33a19200bd..a9b9307d36 100644 --- a/frontend/pages/queries/QueryPage/components/QueryResults/QueryResultsTableConfig.tsx +++ b/frontend/pages/queries/QueryPage/components/QueryResults/QueryResultsTableConfig.tsx @@ -47,28 +47,28 @@ const _unshiftHostname = (headers: IDataColumn[]) => { return newHeaders; }; -const generateResultsTableHeaders = (results: unknown[]): Column[] => { - // Table headers are derived from the shape of the first result. - // Note: It is possible that results may vary from the shape of the first result. - // For example, different versions of osquery may have new columns in a table - // However, this is believed to be a very unlikely scenario and there have been - // no reported issues. - const shape = results[0]; - const keys = - shape && typeof shape === "object" && isPlainObject(shape) - ? Object.keys(shape) - : []; - const headers = keys.map((key) => { +const generateResultsTableHeaders = (results: any[]): Column[] => { + /* Results include an array of objects, each representing a table row + Each key value pair in an object represents a column name and value + To create headers, use JS set to create an array of all unique column names */ + const uniqueColumnNames = Array.from( + results.reduce( + (s, o) => Object.keys(o).reduce((t, k) => t.add(k), s), + new Set() // Set prevents listing duplicate headers + ) + ); + + const headers = uniqueColumnNames.map((key) => { return { - id: key, - title: key, + id: key as string, + title: key as string, Header: (headerProps: IHeaderProps) => ( ), - accessor: key, + accessor: key as string, Cell: (cellProps: ICellProps) => cellProps?.cell?.value || null, Filter: DefaultColumnFilter, // filterType: "text", diff --git a/frontend/pages/queries/QueryPage/screens/RunQuery.tsx b/frontend/pages/queries/QueryPage/screens/RunQuery.tsx index 34e19d39ec..dbceec8069 100644 --- a/frontend/pages/queries/QueryPage/screens/RunQuery.tsx +++ b/frontend/pages/queries/QueryPage/screens/RunQuery.tsx @@ -205,6 +205,7 @@ const RunQuery = ({ isQueryFinished={isQueryFinished} setSelectedTargets={setSelectedTargets} goToQueryEditor={goToQueryEditor} + queryName={storedQuery?.name} targetsTotalCount={targetsTotalCount} /> ); diff --git a/frontend/utilities/convert_to_csv/convert_to_csv.tests.ts b/frontend/utilities/convert_to_csv/convert_to_csv.tests.ts index e0f12e9fd2..5add0df5b1 100644 --- a/frontend/utilities/convert_to_csv/convert_to_csv.tests.ts +++ b/frontend/utilities/convert_to_csv/convert_to_csv.tests.ts @@ -13,7 +13,7 @@ const objArray = [ describe("convertToCSV - utility", () => { it("converts an array of objects to CSV format", () => { - expect(convertToCSV(objArray)).toEqual( + expect(convertToCSV({ objArray })).toEqual( '"first_name","last_name"\n"Mike","Stone"\n"Paul","Simon"' ); }); diff --git a/frontend/utilities/convert_to_csv/index.ts b/frontend/utilities/convert_to_csv/index.ts index cd0a9ce5e3..2492b271d5 100644 --- a/frontend/utilities/convert_to_csv/index.ts +++ b/frontend/utilities/convert_to_csv/index.ts @@ -1,12 +1,25 @@ -import { keys } from "lodash"; +import { ICampaignError } from "interfaces/campaign"; +import { Row, Column } from "react-table"; const defaultFieldSortFunc = (fields: string[]) => fields; -const convertToCSV = ( - objArray: any[], - fieldSortFunc = defaultFieldSortFunc -) => { - const fields = fieldSortFunc(keys(objArray[0])); +interface ConvertToCSV { + objArray: any; // TODO: typing + fieldSortFunc?: (fields: string[]) => string[]; + tableHeaders?: any[]; // TODO: typing +} + +const convertToCSV = ({ + objArray, + fieldSortFunc = defaultFieldSortFunc, + tableHeaders, +}: ConvertToCSV) => { + const tableHeadersStrings: string[] = tableHeaders + ? tableHeaders.map((header: { id: string }) => header.id) // TODO: typing + : Object.keys(objArray[0]); + + const fields = fieldSortFunc(tableHeadersStrings); + // TODO: Remove after v5 when host_hostname is removed rom API response. const hostNameIndex = fields.indexOf("host_hostname"); if (hostNameIndex >= 0) { @@ -14,7 +27,8 @@ const convertToCSV = ( } // Remove end const jsonFields = fields.map((field) => JSON.stringify(field)); - const rows = objArray.map((row) => { + const rows = objArray.map((row: any) => { + // TODO: typing return fields.map((field) => JSON.stringify(row[field])).join(","); }); diff --git a/frontend/utilities/generate_csv/index.ts b/frontend/utilities/generate_csv/index.ts new file mode 100644 index 0000000000..8ee514ef93 --- /dev/null +++ b/frontend/utilities/generate_csv/index.ts @@ -0,0 +1,63 @@ +import convertToCSV from "utilities/convert_to_csv"; +import { Row, Column } from "react-table"; +import { ICampaignError } from "interfaces/campaign"; +import { format } from "date-fns"; + +const reorderCSVFields = (tableHeaders: string[]) => { + const result = tableHeaders.filter((field) => field !== "host_display_name"); + result.unshift("host_display_name"); + + return result; +}; + +export const generateCSVFilename = (descriptor: string) => { + return `${descriptor} (${format(new Date(), "MM-dd-yy hh-mm-ss")}).csv`; +}; + +// Query results and query errors +export const generateCSVQueryResults = ( + rows: Row[], + filename: string, + tableHeaders: Column[] | string[] +) => { + return new global.window.File( + [ + convertToCSV({ + objArray: rows.map((r) => r.original), + fieldSortFunc: reorderCSVFields, + tableHeaders, + }), + ], + filename, + { + type: "text/csv", + } + ); +}; + +// Policy results only +export const generateCSVPolicyResults = ( + rows: { host: string; status: string }[], + filename: string +) => { + return new global.window.File([convertToCSV({ objArray: rows })], filename, { + type: "text/csv", + }); +}; + +// Policy errors only +export const generateCSVPolicyErrors = ( + rows: ICampaignError[], + filename: string +) => { + return new global.window.File([convertToCSV({ objArray: rows })], filename, { + type: "text/csv", + }); +}; + +export default { + generateCSVFilename, + generateCSVQueryResults, + generateCSVPolicyResults, + generateCSVPolicyErrors, +};