From a85f399cac364c2708d7a910c67ea9ea71e095c6 Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Mon, 9 Oct 2023 13:38:34 -0700 Subject: [PATCH] Fleet UI: Query report (table, buttons, api calls, etc) (#14325) ## Issue Cerra #13472 ## Description - Surface query report on the `/queries/{id}` route - Include table buttons to show query and export query - Include results count - Clientside sorting and filtering for columns - Add mock data to frontend integration mocks and to API mocks for concurrent development - 331 + 351 + 2 = 684 lines of code is just mocking data and not actual changes - If modifying sorting/filter, modify the exported results sorting/filter as well - Last fetched column is sentence cased, sortable by chronological order and not alpha order of the readable string (e.g., "a year ago" should be sorted _after_ "over 1 month ago" if sorted most recent to oldest even though a comes before o in the alphabet) ## Screen recordings (Uses mock data) https://github.com/fleetdm/fleet/assets/71795832/22766f2b-3387-4a95-b505-b530dda582fa https://github.com/fleetdm/fleet/assets/71795832/5c2cd8cc-d00e-4ead-b111-e3b33cb7c955 # Checklist for submitter If some of the following don't apply, delete the relevant line. - TODO for QA: Added/updated E2E tests (consider testing some of the features mentioned in the description) - [x] Manual QA for all new/changed functionality --- frontend/__mocks__/queryReportMock.ts | 331 +++++++++++++++++ frontend/interfaces/query_report.ts | 12 + .../QueryDetailsPage/QueryDetailsPage.tsx | 62 +++- .../QueryDetailsPageConfig.tsx | 13 + .../details/QueryDetailsPage/_styles.scss | 4 + .../CachedDetails/CachedDetails.tsx | 14 - .../details/components/CachedDetails/index.ts | 1 - .../components/NoResults/NoResults.tsx | 19 +- .../components/QueryReport/QueryReport.tsx | 142 +++++++ .../QueryReport/QueryReportTableConfig.tsx | 93 +++++ .../components/QueryReport/_styles.scss | 14 + .../details/components/QueryReport/index.ts | 1 + frontend/services/entities/query_report.ts | 50 +++ .../services/mock_service/mocks/config.ts | 2 + .../services/mock_service/mocks/responses.ts | 351 ++++++++++++++++++ frontend/utilities/generate_csv/index.ts | 6 +- 16 files changed, 1075 insertions(+), 40 deletions(-) create mode 100644 frontend/__mocks__/queryReportMock.ts create mode 100644 frontend/interfaces/query_report.ts create mode 100644 frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPageConfig.tsx delete mode 100644 frontend/pages/queries/details/components/CachedDetails/CachedDetails.tsx delete mode 100644 frontend/pages/queries/details/components/CachedDetails/index.ts create mode 100644 frontend/pages/queries/details/components/QueryReport/QueryReport.tsx create mode 100644 frontend/pages/queries/details/components/QueryReport/QueryReportTableConfig.tsx create mode 100644 frontend/pages/queries/details/components/QueryReport/_styles.scss create mode 100644 frontend/pages/queries/details/components/QueryReport/index.ts create mode 100644 frontend/services/entities/query_report.ts diff --git a/frontend/__mocks__/queryReportMock.ts b/frontend/__mocks__/queryReportMock.ts new file mode 100644 index 0000000000..eb538473d9 --- /dev/null +++ b/frontend/__mocks__/queryReportMock.ts @@ -0,0 +1,331 @@ +import { IQueryReport } from "interfaces/query_report"; + +const DEFAULT_QUERY_REPORT_MOCK: IQueryReport = { + query_id: 31, + results: [ + { + host_id: 1, + host_name: "foo", + last_fetched: "2021-01-19T17:08:31Z", + columns: { + model: "Razer Viper", + vendor: "Razer", + model_id: "0078", + }, + }, + { + host_id: 1, + host_name: "foo", + last_fetched: "2021-01-19T17:08:31Z", + columns: { + model: "USB Keyboard", + vendor: "VIA Labs, Inc.", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB Reciever", + vendor: "Logitech", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB Keyboard", + vendor: "Logitech", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "YubiKey OTP+FIDO+CCID", + vendor: "Yubico", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Lenovo USB Optical Mouse", + vendor: "PixArt", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Lenovo Traditional USB Keyboard", + vendor: "Lenovo", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Display Audio", + vendor: "Bose", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB-C Digital AV Multiport Adapter", + vendor: "Apple, Inc.", + model_id: "1460", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB Reciever", + vendor: "Logitech", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB-C Digital AV Multiport Adapter", + vendor: "Apple Inc.", + model_id: "1460", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB Reciever", + vendor: "Logitech", + }, + }, + { + host_id: 3, + host_name: "zoo", + last_fetched: "2022-04-09T17:20:00Z", + columns: { + model: "Logitech Webcam C925e", + model_id: "085b", + }, + }, + { + host_id: 3, + host_name: "zoo", + last_fetched: "2022-04-09T17:20:00Z", + columns: { + model: "Display Audio", + vendor: "Apple Inc.", + }, + }, + { + host_id: 3, + host_name: "zoo", + last_fetched: "2022-04-09T17:20:00Z", + columns: { + model: "Ambient Light Sensor", + vendor: "Apple Inc.", + }, + }, + { + host_id: 3, + host_name: "zoo", + last_fetched: "2022-04-09T17:20:00Z", + columns: { + model: "DELL Laser Mouse", + model_id: "4d51", + }, + }, + { + host_id: 7, + host_name: "Rachel's Magnificent Testing Computer of All Computers", + last_fetched: "2023-09-21T19:03:30Z", + columns: { + model: "AppleUSBVHCIBCE Root Hub Simulation", + vendor: "Apple Inc.", + }, + }, + { + host_id: 7, + host_name: "Rachel's Magnificent Testing Computer of All Computers", + last_fetched: "2023-09-21T19:03:30Z", + columns: { + model: "QuickFire Rapid keyboard", + vendor: "CM Storm", + model_id: "0004", + }, + }, + { + host_id: 7, + host_name: "Rachel's Magnificent Testing Computer of All Computers", + last_fetched: "2023-09-21T19:03:30Z", + columns: { + model: "Lenovo USB Optical Mouse", + vendor: "Lenovo", + }, + }, + { + host_id: 7, + host_name: "Rachel's Magnificent Testing Computer of All Computers", + last_fetched: "2023-09-21T19:03:30Z", + columns: { + model: "YubiKey FIDO+CCID", + vendor: "Yubico", + }, + }, + { + host_id: 4, + host_name: "car", + last_fetched: "2023-01-14T12:40:30Z", + columns: { + model: "USB2.0 Hub", + vendor: "Apple Inc.", + }, + }, + { + host_id: 8, + host_name: "apple man", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "FaceTime HD Camera (Display)", + vendor: "Apple Inc.", + model_id: "1112", + }, + }, + { + host_id: 8, + host_name: "apple man", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Apple Internal Keyboard / Trackpad", + model_id: "027e", + vendor: "Apple Inc.", + }, + }, + { + host_id: 8, + host_name: "apple man", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Apple Thunderbolt Display", + vendor: "Apple Inc.", + model_id: "9227", + }, + }, + { + host_id: 8, + host_name: "apple man", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "AppleUSBXHCI Root Hub Simulation", + vendor: "Apple Inc.", + model_id: "8007", + }, + }, + { + host_id: 8, + host_name: "apple man", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Apple T2 Controller", + vendor: "Apple Inc.", + model_id: "8233", + }, + }, + { + host_id: 5, + host_name: "choo", + last_fetched: "2023-09-03T03:40:30Z", + columns: { + model: "4-Port USB 2.0 Hub", + vendor: "Generic", + }, + }, + { + host_id: 5, + host_name: "choo", + last_fetched: "2023-09-03T03:40:30Z", + columns: { + model: "USB 10_100_1000 LAN", + vendor: "Realtek", + }, + }, + { + host_id: 5, + host_name: "choo", + last_fetched: "2023-09-03T03:40:30Z", + columns: { + model: "Display Audio", + vendor: "Apple Inc.", + }, + }, + { + host_id: 5, + host_name: "choo", + last_fetched: "2023-09-03T03:40:30Z", + columns: { + model: "USB Mouse", + vendor: "Razor", + }, + }, + { + host_id: 5, + host_name: "choo", + last_fetched: "2023-09-03T03:40:30Z", + columns: { + model: "USB Audio", + vendor: "Apple, Inc.", + }, + }, + { + host_id: 6, + host_name: "moo", + last_fetched: "2023-09-20T07:02:34Z", + columns: { + model: "Display Audio", + vendor: "Apple Inc.", + }, + }, + { + host_id: 6, + host_name: "moo", + last_fetched: "2023-09-20T07:02:34Z", + columns: { + model: "USB Reciever", + vendor: "Logitech", + }, + }, + { + host_id: 6, + host_name: "moo", + last_fetched: "2023-09-20T07:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + ], +}; + +const createMockQueryReport = ( + overrides?: Partial +): IQueryReport => { + return { ...DEFAULT_QUERY_REPORT_MOCK, ...overrides }; +}; + +export default createMockQueryReport; diff --git a/frontend/interfaces/query_report.ts b/frontend/interfaces/query_report.ts new file mode 100644 index 0000000000..9310fcc4e8 --- /dev/null +++ b/frontend/interfaces/query_report.ts @@ -0,0 +1,12 @@ +export interface IQueryReportResultRow { + host_id: number; + host_name: string; + last_fetched: string; + columns: any; +} + +// Query report +export interface IQueryReport { + query_id: number; + results: IQueryReportResultRow[]; +} diff --git a/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx index 17c46c3ade..6c52aaeda8 100644 --- a/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx +++ b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx @@ -1,4 +1,4 @@ -import React, { useContext } from "react"; +import React, { useContext, useState } from "react"; import { useQuery } from "react-query"; import { InjectedRouter, Params } from "react-router/lib/Router"; import { useErrorHandler } from "react-error-boundary"; @@ -12,8 +12,10 @@ import { IGetQueryResponse, ISchedulableQuery, } from "interfaces/schedulable_query"; +import { IQueryReport } from "interfaces/query_report"; import queryAPI from "services/entities/queries"; +import queryReportAPI, { ISortOption } from "services/entities/query_report"; import Spinner from "components/Spinner/Spinner"; import Button from "components/buttons/Button"; @@ -23,15 +25,20 @@ import TooltipWrapper from "components/TooltipWrapper/TooltipWrapper"; import QueryAutomationsStatusIndicator from "pages/queries/ManageQueriesPage/components/QueryAutomationsStatusIndicator/QueryAutomationsStatusIndicator"; import DataError from "components/DataError/DataError"; import LogDestinationIndicator from "components/LogDestinationIndicator/LogDestinationIndicator"; -import CachedDetails from "../components/CachedDetails/CachedDetails"; +import QueryReport from "../components/QueryReport/QueryReport"; import NoResults from "../components/NoResults/NoResults"; +import { + DEFAULT_SORT_HEADER, + DEFAULT_SORT_DIRECTION, +} from "./QueryDetailsPageConfig"; + interface IQueryDetailsPageProps { router: InjectedRouter; // v3 params: Params; location: { pathname: string; - query: { team_id?: string }; + query: { team_id?: string; order_key?: string; order_direction?: string }; search: string; }; } @@ -43,7 +50,20 @@ const QueryDetailsPage = ({ params: { id: paramsQueryId }, location, }: IQueryDetailsPageProps): JSX.Element => { - const queryId = paramsQueryId ? parseInt(paramsQueryId, 10) : null; + const queryId = parseInt(paramsQueryId, 10); + const queryParams = location.query; + + // Functions to avoid race conditions + const initialSortBy: ISortOption[] = (() => { + return [ + { + key: queryParams?.order_key ?? DEFAULT_SORT_HEADER, + direction: queryParams?.order_direction ?? DEFAULT_SORT_DIRECTION, + }, + ]; + })(); + + const [sortBy, setSortBy] = useState(initialSortBy); const { currentTeamName: teamNameForQuery, @@ -91,7 +111,7 @@ const QueryDetailsPage = ({ error: storedQueryError, } = useQuery( ["query", queryId], - () => queryAPI.load(queryId as number), + () => queryAPI.load(queryId), { enabled: !!queryId, refetchOnWindowFocus: false, @@ -111,8 +131,26 @@ const QueryDetailsPage = ({ } ); - const isLoading = isStoredQueryLoading; // TODO: Add || isCachedResultsLoading for new API response - const isApiError = storedQueryError || false; // TODO: Add || isCachedResultsError for new API response + const { + isLoading: isQueryReportLoading, + data: queryReport, + error: queryReportError, + } = useQuery( + [], + () => + queryReportAPI.load({ + sortBy, + id: queryId, + }), + { + enabled: !!queryId, + refetchOnWindowFocus: false, + onError: (error) => handlePageError(error), + } + ); + + const isLoading = isStoredQueryLoading || isQueryReportLoading; + const isApiError = storedQueryError || queryReportError; const renderHeader = () => { const canEditQuery = @@ -172,7 +210,9 @@ const QueryDetailsPage = ({ {!isLoading && !isApiError && (
- + on, data is sent according to a query’s frequency.`} + > Automations: ); } - return ; // TODO: Everything related to new APIs including surfacing errorsOnly + return ; // TODO: Everything related to new APIs including surfacing errorsOnly }; return ( diff --git a/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPageConfig.tsx b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPageConfig.tsx new file mode 100644 index 0000000000..10cc329d00 --- /dev/null +++ b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPageConfig.tsx @@ -0,0 +1,13 @@ +// TODO +export const QUERY_DETAILS_PAGE_FILTER_KEYS = ["model", "vendor"] as const; + +// TODO: refactor to use this type as the location.query prop of the page +export type QueryDetailsPageQueryParams = Record< + | "order_key" + | "order_direction" + | typeof QUERY_DETAILS_PAGE_FILTER_KEYS[number], + string +>; + +export const DEFAULT_SORT_HEADER = "host_name"; +export const DEFAULT_SORT_DIRECTION = "asc"; diff --git a/frontend/pages/queries/details/QueryDetailsPage/_styles.scss b/frontend/pages/queries/details/QueryDetailsPage/_styles.scss index 9b0a5a0a25..a48b335706 100644 --- a/frontend/pages/queries/details/QueryDetailsPage/_styles.scss +++ b/frontend/pages/queries/details/QueryDetailsPage/_styles.scss @@ -39,6 +39,10 @@ &__log-destination { display: flex; gap: $pad-small; + + .component__tooltip-wrapper__element { + font-weight: $bold; + } } .empty-table__inner { diff --git a/frontend/pages/queries/details/components/CachedDetails/CachedDetails.tsx b/frontend/pages/queries/details/components/CachedDetails/CachedDetails.tsx deleted file mode 100644 index ab5255e3f0..0000000000 --- a/frontend/pages/queries/details/components/CachedDetails/CachedDetails.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from "react"; - -// TODO: This whole section -// interface ICachedDetailsProps { -// -// } - -const baseClass = "cached-details"; - -const CachedDetails = (): JSX.Element => { - return
TODO
; -}; - -export default CachedDetails; diff --git a/frontend/pages/queries/details/components/CachedDetails/index.ts b/frontend/pages/queries/details/components/CachedDetails/index.ts deleted file mode 100644 index b50b73552b..0000000000 --- a/frontend/pages/queries/details/components/CachedDetails/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./CachedDetails"; diff --git a/frontend/pages/queries/details/components/NoResults/NoResults.tsx b/frontend/pages/queries/details/components/NoResults/NoResults.tsx index 7897facb23..1657923339 100644 --- a/frontend/pages/queries/details/components/NoResults/NoResults.tsx +++ b/frontend/pages/queries/details/components/NoResults/NoResults.tsx @@ -14,7 +14,6 @@ interface INoResultsProps { disabledCachingGlobally: boolean; discardDataEnabled: boolean; loggingSnapshot: boolean; - errorsOnly: boolean; } const baseClass = "no-results"; @@ -26,7 +25,6 @@ const NoResults = ({ disabledCachingGlobally, discardDataEnabled, loggingSnapshot, - errorsOnly, }: INoResultsProps): JSX.Element => { // Returns how many seconds it takes to expect a cached update const secondsCheckbackTime = () => { @@ -92,14 +90,15 @@ const NoResults = ({ ); } - if (errorsOnly) { - return ( - <> - This query had trouble collecting data on some hosts. Check out the{" "} - Errors tab to see why. - - ); - } + // No errors will be reported in V1 + // if (errorsOnly) { + // return ( + // <> + // This query had trouble collecting data on some hosts. Check out the{" "} + // Errors tab to see why. + // + // ); + // } return "This query has returned no data so far."; }; diff --git a/frontend/pages/queries/details/components/QueryReport/QueryReport.tsx b/frontend/pages/queries/details/components/QueryReport/QueryReport.tsx new file mode 100644 index 0000000000..4456fe402c --- /dev/null +++ b/frontend/pages/queries/details/components/QueryReport/QueryReport.tsx @@ -0,0 +1,142 @@ +import React, { useState, useContext, useEffect } from "react"; + +import { Row, Column } from "react-table"; +import FileSaver from "file-saver"; +import { QueryContext } from "context/query"; + +import { + generateCSVFilename, + generateCSVQueryResults, +} from "utilities/generate_csv"; +import { IQueryReport, IQueryReportResultRow } from "interfaces/query_report"; + +import Button from "components/buttons/Button"; +import Icon from "components/Icon/Icon"; +import TableContainer from "components/TableContainer"; +import ShowQueryModal from "components/modals/ShowQueryModal"; + +import generateResultsTableHeaders from "./QueryReportTableConfig"; + +interface IQueryReportProps { + queryReport?: IQueryReport; +} + +const baseClass = "query-report"; +const CSV_TITLE = "Query"; + +const tableResults = (results: IQueryReportResultRow[]) => { + return results.map((result: IQueryReportResultRow) => { + const hostInfoColumns = { + host_display_name: result.host_name, + last_fetched: result.last_fetched, + }; + + // hostInfoColumns displays the host metadata that is returned with every query + // result.columns are the variable columns returned by the API that differ per query + return { ...hostInfoColumns, ...result.columns }; + }); +}; + +const QueryReport = ({ queryReport }: IQueryReportProps): JSX.Element => { + const { lastEditedQueryName, lastEditedQueryBody } = useContext(QueryContext); + + const [showQueryModal, setShowQueryModal] = useState(false); + const [filteredResults, setFilteredResults] = useState( + tableResults(queryReport?.results || []) + ); + const [tableHeaders, setTableHeaders] = useState([]); + + useEffect(() => { + if (queryReport && queryReport.results && queryReport.results.length > 0) { + const generatedTableHeaders = generateResultsTableHeaders( + tableResults(queryReport.results) + ); + // Update tableHeaders if new headers are found + if (generatedTableHeaders !== tableHeaders) { + setTableHeaders(generatedTableHeaders); + } + } + }, [queryReport]); // Cannot use tableHeaders as it will cause infinite loop with setTableHeaders + + const onExportQueryResults = (evt: React.MouseEvent) => { + evt.preventDefault(); + FileSaver.saveAs( + generateCSVQueryResults( + filteredResults, + generateCSVFilename( + `${lastEditedQueryName || CSV_TITLE} - Query Report` + ), + tableHeaders + ) + ); + }; + + const onShowQueryModal = () => { + setShowQueryModal(!showQueryModal); + }; + + const renderNoResults = () => { + return

TODO

; + }; + + const renderTableButtons = () => { + return ( +
+ + +
+ ); + }; + + const renderTable = () => { + return ( +
+ renderTableButtons()} + setExportRows={setFilteredResults} + /> +
+ ); + }; + + return ( +
+ {renderTable()} + {showQueryModal && ( + + )} +
+ ); +}; + +export default QueryReport; diff --git a/frontend/pages/queries/details/components/QueryReport/QueryReportTableConfig.tsx b/frontend/pages/queries/details/components/QueryReport/QueryReportTableConfig.tsx new file mode 100644 index 0000000000..1babdb0969 --- /dev/null +++ b/frontend/pages/queries/details/components/QueryReport/QueryReportTableConfig.tsx @@ -0,0 +1,93 @@ +/* eslint-disable react/prop-types */ +// disable this rule as it was throwing an error in Header and Cell component +// definitions for the selection row for some reason when we dont really need it. +import React from "react"; + +import { + CellProps, + Column, + ColumnInstance, + ColumnInterface, + HeaderProps, + TableInstance, +} from "react-table"; + +import DefaultColumnFilter from "components/TableContainer/DataTable/DefaultColumnFilter"; +import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCell"; + +import { humanHostLastSeen } from "utilities/helpers"; + +type IHeaderProps = HeaderProps & { + column: ColumnInstance & IDataColumn; +}; + +type ICellProps = CellProps; + +interface IDataColumn extends ColumnInterface { + title?: string; + accessor: string; +} + +const _unshiftHostname = (headers: IDataColumn[]) => { + const newHeaders = [...headers]; + const displayNameIndex = headers.findIndex( + (h) => h.id === "host_display_name" + ); + if (displayNameIndex >= 0) { + // remove hostname header from headers + const [displayNameHeader] = newHeaders.splice(displayNameIndex, 1); + // reformat title and insert at start of headers array + newHeaders.unshift({ ...displayNameHeader, title: "Host" }); + } + // TODO: Remove after v5 when host_hostname is removed rom API response. + const hostNameIndex = headers.findIndex((h) => h.id === "host_hostname"); + if (hostNameIndex >= 0) { + newHeaders.splice(hostNameIndex, 1); + } + // end remove + return newHeaders; +}; + +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 as string, + title: key as string, + Header: (headerProps: IHeaderProps) => ( + + ), + accessor: key as string, + Cell: (cellProps: ICellProps) => { + // Filters chronologically by date, but UI displays readable last fetched + if (cellProps.column.id === "last_fetched") { + return humanHostLastSeen(cellProps?.cell?.value); + } + return cellProps?.cell?.value || null; + }, + Filter: DefaultColumnFilter, + filterType: "text", + disableSortBy: false, + }; + }); + return _unshiftHostname(headers); +}; + +export default generateResultsTableHeaders; diff --git a/frontend/pages/queries/details/components/QueryReport/_styles.scss b/frontend/pages/queries/details/components/QueryReport/_styles.scss new file mode 100644 index 0000000000..6ca33cb40b --- /dev/null +++ b/frontend/pages/queries/details/components/QueryReport/_styles.scss @@ -0,0 +1,14 @@ +.query-report { + &__wrapper { + margin-top: $pad-large; + + .host_id__header { + width: 95px; // Min width for 6 digits host IDs + } + } + + &__results-cta { + display: flex; + gap: $pad-medium; + } +} diff --git a/frontend/pages/queries/details/components/QueryReport/index.ts b/frontend/pages/queries/details/components/QueryReport/index.ts new file mode 100644 index 0000000000..7e9fe702db --- /dev/null +++ b/frontend/pages/queries/details/components/QueryReport/index.ts @@ -0,0 +1 @@ +export { default } from "./QueryReport"; diff --git a/frontend/services/entities/query_report.ts b/frontend/services/entities/query_report.ts new file mode 100644 index 0000000000..9dbf13834e --- /dev/null +++ b/frontend/services/entities/query_report.ts @@ -0,0 +1,50 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +// import sendRequest from "services"; +import endpoints from "utilities/endpoints"; + +import { buildQueryStringFromParams } from "utilities/url"; + +// Mock API requests to be used in developing FE for #7766 in parallel with BE development +import { sendRequest } from "services/mock_service/service/service"; + +export interface ISortOption { + key: string; + direction: string; +} + +export interface ILoadQueryReportOptions { + id: number; + sortBy: ISortOption[]; +} + +const getSortParams = (sortOptions?: ISortOption[]) => { + if (sortOptions === undefined || sortOptions.length === 0) { + return {}; + } + + const sortItem = sortOptions[0]; + return { + order_key: sortItem.key, + order_direction: sortItem.direction, + }; +}; + +export default { + load: ({ id, sortBy }: ILoadQueryReportOptions) => { + const sortParams = getSortParams(sortBy); + + const { QUERIES } = endpoints; + + const queryParams = { + order_key: sortParams.order_key, + order_direction: sortParams.order_direction, + }; + + const queryString = buildQueryStringFromParams(queryParams); + + // const endpoint = `${QUERIES}/${id}/report`; + const endpoint = `${QUERIES}/113/report`; + const path = `${endpoint}?${queryString}`; + return sendRequest("GET", path); + }, +}; diff --git a/frontend/services/mock_service/mocks/config.ts b/frontend/services/mock_service/mocks/config.ts index 50ebcbf1f9..5fd108c994 100644 --- a/frontend/services/mock_service/mocks/config.ts +++ b/frontend/services/mock_service/mocks/config.ts @@ -33,6 +33,8 @@ const REQUEST_RESPONSE_MAPPINGS: IResponses = { "queries/7": RESPONSES.globalQuery6, "queries/8": RESPONSES.teamQuery2, "queries?team_id=13": RESPONSES.teamQueries, + "queries/113/report?order_key=host_name&order_direction=asc": + RESPONSES.queryReport, }, POST: { // request body is ISelectedTargets diff --git a/frontend/services/mock_service/mocks/responses.ts b/frontend/services/mock_service/mocks/responses.ts index d20d275f8f..860259f8c4 100644 --- a/frontend/services/mock_service/mocks/responses.ts +++ b/frontend/services/mock_service/mocks/responses.ts @@ -598,6 +598,356 @@ const teamQueries = { ], }; +const queryReport = { + query_id: 31, + results: [ + { + host_id: 1, + host_name: "foo", + last_fetched: "2021-01-19T17:08:31Z", + columns: { + model: "Razer Viper", + vendor: "Razer", + model_id: "0078", + }, + }, + { + host_id: 1, + host_name: "foo", + last_fetched: "2021-01-19T17:08:31Z", + columns: { + model: "USB Keyboard", + vendor: "VIA Labs, Inc.", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB Reciever", + vendor: "Logitech", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB Keyboard", + vendor: "Logitech", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "YubiKey OTP+FIDO+CCID", + vendor: "Yubico", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Lenovo USB Optical Mouse", + vendor: "PixArt", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Lenovo Traditional USB Keyboard", + vendor: "Lenovo", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Display Audio", + vendor: "Bose", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB-C Digital AV Multiport Adapter", + vendor: "Apple, Inc.", + model_id: "1460", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB Reciever", + vendor: "Logitech", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB-C Digital AV Multiport Adapter", + vendor: "Apple Inc.", + model_id: "1460", + }, + }, + { + host_id: 2, + host_name: "bar", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "USB Reciever", + vendor: "Logitech", + }, + }, + { + host_id: 3, + host_name: "zoo", + last_fetched: "2022-04-09T17:20:00Z", + columns: { + model: "Logitech Webcam C925e", + model_id: "085b", + }, + }, + { + host_id: 3, + host_name: "zoo", + last_fetched: "2022-04-09T17:20:00Z", + columns: { + model: "Display Audio", + vendor: "Apple Inc.", + }, + }, + { + host_id: 3, + host_name: "zoo", + last_fetched: "2022-04-09T17:20:00Z", + columns: { + model: "Ambient Light Sensor", + vendor: "Apple Inc.", + }, + }, + { + host_id: 3, + host_name: "zoo", + last_fetched: "2022-04-09T17:20:00Z", + columns: { + model: "DELL Laser Mouse", + model_id: "4d51", + }, + }, + { + host_id: 7, + host_name: "Rachel's Magnificent Testing Computer of All Computers", + last_fetched: "2023-09-21T19:03:30Z", + columns: { + model: "AppleUSBVHCIBCE Root Hub Simulation", + vendor: "Apple Inc.", + }, + }, + { + host_id: 7, + host_name: "Rachel's Magnificent Testing Computer of All Computers", + last_fetched: "2023-09-21T19:03:30Z", + columns: { + model: "QuickFire Rapid keyboard", + vendor: "CM Storm", + model_id: "0004", + }, + }, + { + host_id: 7, + host_name: "Rachel's Magnificent Testing Computer of All Computers", + last_fetched: "2023-09-21T19:03:30Z", + columns: { + model: "Lenovo USB Optical Mouse", + vendor: "Lenovo", + }, + }, + { + host_id: 7, + host_name: "Rachel's Magnificent Testing Computer of All Computers", + last_fetched: "2023-09-21T19:03:30Z", + columns: { + model: "YubiKey FIDO+CCID", + vendor: "Yubico", + }, + }, + { + host_id: 4, + host_name: "car", + last_fetched: "2023-01-14T12:40:30Z", + columns: { + model: "USB2.0 Hub", + vendor: "Apple Inc.", + }, + }, + { + host_id: 8, + host_name: "apple man", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "FaceTime HD Camera (Display)", + vendor: "Apple Inc.", + model_id: "1112", + }, + }, + { + host_id: 8, + host_name: "apple man", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Apple Internal Keyboard / Trackpad", + model_id: "027e", + vendor: "Apple Inc.", + }, + }, + { + host_id: 8, + host_name: "apple man", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Apple Thunderbolt Display", + vendor: "Apple Inc.", + model_id: "9227", + }, + }, + { + host_id: 8, + host_name: "apple man", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "AppleUSBXHCI Root Hub Simulation", + vendor: "Apple Inc.", + model_id: "8007", + }, + }, + { + host_id: 8, + host_name: "apple man", + last_fetched: "2021-01-19T17:20:00Z", + columns: { + model: "Apple T2 Controller", + vendor: "Apple Inc.", + model_id: "8233", + }, + }, + { + host_id: 5, + host_name: "choo", + last_fetched: "2023-09-03T03:40:30Z", + columns: { + model: "4-Port USB 2.0 Hub", + vendor: "Generic", + }, + }, + { + host_id: 5, + host_name: "choo", + last_fetched: "2023-09-03T03:40:30Z", + columns: { + model: "USB 10_100_1000 LAN", + vendor: "Realtek", + }, + }, + { + host_id: 5, + host_name: "choo", + last_fetched: "2023-09-03T03:40:30Z", + columns: { + model: "Display Audio", + vendor: "Apple Inc.", + }, + }, + { + host_id: 5, + host_name: "choo", + last_fetched: "2023-09-03T03:40:30Z", + columns: { + model: "USB Mouse", + vendor: "Razor", + }, + }, + { + host_id: 5, + host_name: "choo", + last_fetched: "2023-09-03T03:40:30Z", + columns: { + model: "USB Audio", + vendor: "Apple, Inc.", + }, + }, + { + host_id: 6, + host_name: "moo", + last_fetched: "2023-09-20T07:02:34Z", + columns: { + model: "Display Audio", + vendor: "Apple Inc.", + }, + }, + { + host_id: 6, + host_name: "moo", + last_fetched: "2023-09-20T07:02:34Z", + columns: { + model: "USB Reciever", + vendor: "Logitech", + }, + }, + { + host_id: 6, + host_name: "moo", + last_fetched: "2023-09-20T07:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + { + host_id: 9, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "Display Audio", + vendor: "Apple Inc.", + }, + }, + { + host_id: 9, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "USB Reciever", + vendor: "Logitech", + }, + }, + { + host_id: 9, + host_name: "moo moo", + last_fetched: "2023-09-28T02:02:34Z", + columns: { + model: "LG Monitor Controls", + vendor: "LG Electronics Inc.", + model_id: "9a39", + }, + }, + ], +}; + const globalQuery1 = { query: globalQueries.queries[0] }; const globalQuery2 = { query: globalQueries.queries[1] }; const globalQuery3 = { query: globalQueries.queries[2] }; @@ -611,6 +961,7 @@ export default { count, hosts, labels, + queryReport, globalQueries, globalQuery1, globalQuery2, diff --git a/frontend/utilities/generate_csv/index.ts b/frontend/utilities/generate_csv/index.ts index 8ee514ef93..501441a887 100644 --- a/frontend/utilities/generate_csv/index.ts +++ b/frontend/utilities/generate_csv/index.ts @@ -14,7 +14,7 @@ export const generateCSVFilename = (descriptor: string) => { return `${descriptor} (${format(new Date(), "MM-dd-yy hh-mm-ss")}).csv`; }; -// Query results and query errors +// Live query results, live query errors, and query report export const generateCSVQueryResults = ( rows: Row[], filename: string, @@ -35,7 +35,7 @@ export const generateCSVQueryResults = ( ); }; -// Policy results only +// Live policy results only export const generateCSVPolicyResults = ( rows: { host: string; status: string }[], filename: string @@ -45,7 +45,7 @@ export const generateCSVPolicyResults = ( }); }; -// Policy errors only +// Live policy errors only export const generateCSVPolicyErrors = ( rows: ICampaignError[], filename: string