diff --git a/changes/14415-host-query-reports b/changes/14415-host-query-reports new file mode 100644 index 0000000000..fd80b1b7e5 --- /dev/null +++ b/changes/14415-host-query-reports @@ -0,0 +1,3 @@ +- Implement host query reports: view query results on a per host basis +- Host Detail Query tab now displays all running queries and queries with result data +- Fleet now includes Kung Fu Fighting because it's fast as lightning diff --git a/frontend/components/App/App.tsx b/frontend/components/App/App.tsx index b65d7828ea..1f844da6de 100644 --- a/frontend/components/App/App.tsx +++ b/frontend/components/App/App.tsx @@ -24,6 +24,7 @@ import Fleet404 from "pages/errors/Fleet404"; import Fleet500 from "pages/errors/Fleet500"; import Spinner from "components/Spinner"; import { QueryParams } from "utilities/url"; +import { DOCUMENT_TITLE_SUFFIX } from "utilities/constants"; interface IAppProps { children: JSX.Element; @@ -122,7 +123,7 @@ const App = ({ children, location }: IAppProps): JSX.Element => { !config?.mdm.enabled_and_configured && curTitle?.path === "/controls/os-updates" ) { - curTitle.title = "Manage OS hosts | Fleet for osquery"; + curTitle.title = `Manage OS hosts | ${DOCUMENT_TITLE_SUFFIX}`; } if (curTitle && curTitle.title) { diff --git a/frontend/components/BackLink/BackLink.tsx b/frontend/components/BackLink/BackLink.tsx index b93ce67bd0..b31aab82f1 100644 --- a/frontend/components/BackLink/BackLink.tsx +++ b/frontend/components/BackLink/BackLink.tsx @@ -13,8 +13,6 @@ interface IBackLinkProps { const baseClass = "back-link"; const BackLink = ({ text, path, className }: IBackLinkProps): JSX.Element => { - const backLinkClass = classnames(baseClass, className); - const onClick = (): void => { if (path) { browserHistory.push(path); @@ -22,13 +20,13 @@ const BackLink = ({ text, path, className }: IBackLinkProps): JSX.Element => { }; return ( - + <> - + {text} diff --git a/frontend/components/BackLink/_styles.scss b/frontend/components/BackLink/_styles.scss index cb4d9f2a6e..aa6854afd3 100644 --- a/frontend/components/BackLink/_styles.scss +++ b/frontend/components/BackLink/_styles.scss @@ -1,18 +1,3 @@ .back-link { - display: inline-flex; - align-items: center; - padding: $pad-small $pad-xxsmall; // larger clickable area - border-radius: 3px; // Visible while tabbing; - gap: $pad-xsmall; - - &:hover { - color: $core-vibrant-blue-over; - text-decoration: underline; - - svg { - path { - stroke: $core-vibrant-blue-over; - } - } - } + @include direction-link; } diff --git a/frontend/components/EmptyTable/EmptyTable.tsx b/frontend/components/EmptyTable/EmptyTable.tsx index 8d0c39249c..75b683c120 100644 --- a/frontend/components/EmptyTable/EmptyTable.tsx +++ b/frontend/components/EmptyTable/EmptyTable.tsx @@ -35,8 +35,12 @@ const EmptyTable = ({ )}
{header &&

{header}

} - {info &&

{info}

} - {additionalInfo &&

{additionalInfo}

} + {info &&
{info}
} + {additionalInfo && ( +
+ {additionalInfo} +
+ )}
{primaryButton && (
diff --git a/frontend/components/EmptyTable/_styles.scss b/frontend/components/EmptyTable/_styles.scss index 15517d8d8f..14b8e3ab93 100644 --- a/frontend/components/EmptyTable/_styles.scss +++ b/frontend/components/EmptyTable/_styles.scss @@ -21,13 +21,6 @@ margin: 0; } - p { - text-align: center; - color: $core-fleet-blue; - font-size: $x-small; - margin: 0; - } - ul { margin: 0; padding: 0; @@ -43,6 +36,18 @@ } } } + &__info { + max-width: 350px; + } + + &__info, + &__additional-info { + line-height: 1.5; + text-align: center; + color: $core-fleet-blue; + font-size: $x-small; + margin: 0; + } &__cta-buttons { display: flex; diff --git a/frontend/components/LiveQuery/TargetsInput/TargetsInput.tsx b/frontend/components/LiveQuery/TargetsInput/TargetsInput.tsx index ff188cb419..84182dcd7a 100644 --- a/frontend/components/LiveQuery/TargetsInput/TargetsInput.tsx +++ b/frontend/components/LiveQuery/TargetsInput/TargetsInput.tsx @@ -63,7 +63,7 @@ const TargetsInput = ({ {isActiveSearch && (
): JSX.Element => { diff --git a/frontend/components/TableContainer/DataTable/LiveQueryIssueCell/LiveQueryIssueCell.tsx b/frontend/components/TableContainer/DataTable/LiveQueryIssueCell/LiveQueryIssueCell.tsx index b18d63efe7..cb789aa7dd 100644 --- a/frontend/components/TableContainer/DataTable/LiveQueryIssueCell/LiveQueryIssueCell.tsx +++ b/frontend/components/TableContainer/DataTable/LiveQueryIssueCell/LiveQueryIssueCell.tsx @@ -2,6 +2,7 @@ import React from "react"; import ReactTooltip from "react-tooltip"; import Icon from "components/Icon"; +import { COLORS } from "styles/var/colors"; interface ILiveQueryIssueCellProps { displayName: string; @@ -38,7 +39,7 @@ const LiveQueryIssueCell = ({ diff --git a/frontend/components/TableContainer/DataTable/PillCell/PillCell.tsx b/frontend/components/TableContainer/DataTable/PillCell/PillCell.tsx index 8fc83621ca..1b482c192b 100644 --- a/frontend/components/TableContainer/DataTable/PillCell/PillCell.tsx +++ b/frontend/components/TableContainer/DataTable/PillCell/PillCell.tsx @@ -3,6 +3,7 @@ import classnames from "classnames"; import { uniqueId } from "lodash"; import ReactTooltip from "react-tooltip"; +import { COLORS } from "styles/var/colors"; interface IPillCellProps { value: { indicator: string; id: number }; @@ -97,7 +98,7 @@ const PillCell = ({ diff --git a/frontend/components/TableContainer/DataTable/TextCell/TextCell.tsx b/frontend/components/TableContainer/DataTable/TextCell/TextCell.tsx index eabf2da046..d086ef5e39 100644 --- a/frontend/components/TableContainer/DataTable/TextCell/TextCell.tsx +++ b/frontend/components/TableContainer/DataTable/TextCell/TextCell.tsx @@ -1,6 +1,7 @@ import { uniqueId } from "lodash"; import React from "react"; import ReactTooltip from "react-tooltip"; +import { COLORS } from "styles/var/colors"; import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants"; interface ITextCellProps { @@ -38,7 +39,7 @@ const TextCell = ({ {emptyCellTooltipText} diff --git a/frontend/components/TableContainer/DataTable/TooltipTruncatedTextCell/TooltipTruncatedTextCell.tsx b/frontend/components/TableContainer/DataTable/TooltipTruncatedTextCell/TooltipTruncatedTextCell.tsx index dd6cd61023..96f28d8f6c 100644 --- a/frontend/components/TableContainer/DataTable/TooltipTruncatedTextCell/TooltipTruncatedTextCell.tsx +++ b/frontend/components/TableContainer/DataTable/TooltipTruncatedTextCell/TooltipTruncatedTextCell.tsx @@ -4,6 +4,7 @@ import classnames from "classnames"; import ReactTooltip from "react-tooltip"; import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants"; +import { COLORS } from "styles/var/colors"; interface ITooltipTruncatedTextCellProps { value: string | number | boolean; @@ -46,7 +47,7 @@ const TooltipTruncatedTextCell = ({ @@ -415,7 +416,7 @@ const TableContainer = ({ > { it("renders show text", async () => { @@ -75,18 +74,18 @@ describe("Reveal button", () => { }); it("renders tooltip on hover if provided", async () => { - const { user } = renderWithSetup( + render( ); await fireEvent.mouseEnter(screen.getByText(SHOW_TEXT)); - expect(screen.getByText(TOOLTIP_HTML)).toBeInTheDocument(); + expect(screen.getByText(TOOLTIP_CONTENT)).toBeInTheDocument(); }); }); diff --git a/frontend/components/buttons/RevealButton/RevealButton.tsx b/frontend/components/buttons/RevealButton/RevealButton.tsx index 08e29460db..c1db68f9d9 100644 --- a/frontend/components/buttons/RevealButton/RevealButton.tsx +++ b/frontend/components/buttons/RevealButton/RevealButton.tsx @@ -12,7 +12,7 @@ export interface IRevealButtonProps { caretPosition?: "before" | "after"; autofocus?: boolean; disabled?: boolean; - tooltipHtml?: string; + tooltipContent?: React.ReactNode; onClick?: | ((value?: any) => void) | ((evt: React.MouseEvent) => void); @@ -28,7 +28,7 @@ const RevealButton = ({ caretPosition, autofocus, disabled, - tooltipHtml, + tooltipContent, onClick, }: IRevealButtonProps): JSX.Element => { const classNames = classnames(baseClass, className); @@ -36,8 +36,8 @@ const RevealButton = ({ const buttonContent = () => { const text = isShowing ? hideText : showText; - const buttonText = tooltipHtml ? ( - {text} + const buttonText = tooltipContent ? ( + {text} ) : ( text ); diff --git a/frontend/components/queries/PackQueriesTable/PackQueriesTable.tsx b/frontend/components/queries/PackQueriesTable/PackQueriesTable.tsx index 5a182d37e5..4e1318e373 100644 --- a/frontend/components/queries/PackQueriesTable/PackQueriesTable.tsx +++ b/frontend/components/queries/PackQueriesTable/PackQueriesTable.tsx @@ -75,7 +75,7 @@ const PackQueriesTable = ({
{scheduledQueries?.length ? ( {internationalTimeFormat(activityCreatedAt)} diff --git a/frontend/pages/DashboardPage/cards/MDM/MDM.tsx b/frontend/pages/DashboardPage/cards/MDM/MDM.tsx index 45ce2788fc..f5670ac9d4 100644 --- a/frontend/pages/DashboardPage/cards/MDM/MDM.tsx +++ b/frontend/pages/DashboardPage/cards/MDM/MDM.tsx @@ -112,7 +112,7 @@ const Mdm = ({ ) : ( ) : ( ) : ( ) : ( ) : ( ) : ( ) : ( { return (
JSX.Element) | string; accessor: string; @@ -53,7 +53,7 @@ type IDataColumn = { | ((props: IStatusCellProps) => JSX.Element); }; -export const TABLE_HEADERS: IDataColumn[] = [ +export const COLUMN_CONFIGS: IColumnConfig[] = [ { title: "Status", Header: "Status", diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTable.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTable.tsx index e42d6decfc..5f8933fa8a 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTable.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTable.tsx @@ -53,7 +53,7 @@ const SoftwareTitleDetailsTable = ({
diff --git a/frontend/pages/admin/IntegrationsPage/cards/Integrations/Integrations.tsx b/frontend/pages/admin/IntegrationsPage/cards/Integrations/Integrations.tsx index ef61ed6f2a..82f497ccc5 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/Integrations/Integrations.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/Integrations/Integrations.tsx @@ -407,7 +407,7 @@ const Integrations = (): JSX.Element => { ) : ( diff --git a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/MembersPage/MembersPage.tsx b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/MembersPage/MembersPage.tsx index ab4e7f9931..9b638adbae 100644 --- a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/MembersPage/MembersPage.tsx +++ b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/MembersPage/MembersPage.tsx @@ -31,7 +31,7 @@ import AddMemberModal from "./components/AddMemberModal"; import RemoveMemberModal from "./components/RemoveMemberModal"; import { - generateTableHeaders, + generateColumnConfigs, generateDataSet, IMembersTableData, } from "./MembersPageTableConfig"; @@ -412,7 +412,7 @@ const MembersPage = ({ location, router }: IMembersPageProps): JSX.Element => { return ; } - const tableHeaders = generateTableHeaders(onActionSelection); + const columnConfigs = generateColumnConfigs(onActionSelection); return (
@@ -431,7 +431,7 @@ const MembersPage = ({ location, router }: IMembersPageProps): JSX.Element => { ) : ( void ): IDataColumn[] => { return [ @@ -92,7 +93,7 @@ const generateTableHeaders = ( type="dark" effect="solid" id={`api-only-tooltip-${cellProps.row.original.id}`} - backgroundColor="#3e4771" + backgroundColor={COLORS["tooltip-bg"]} clickable delayHide={200} // need delay set to hover using clickable > @@ -233,4 +234,4 @@ const generateDataSet = ( return [...enhanceMembersData(teamId, users)]; }; -export { generateTableHeaders, generateDataSet }; +export { generateColumnConfigs, generateDataSet }; diff --git a/frontend/pages/admin/TeamManagementPage/TeamManagementPage.tsx b/frontend/pages/admin/TeamManagementPage/TeamManagementPage.tsx index 712fb6808a..7b946149e9 100644 --- a/frontend/pages/admin/TeamManagementPage/TeamManagementPage.tsx +++ b/frontend/pages/admin/TeamManagementPage/TeamManagementPage.tsx @@ -270,7 +270,7 @@ const TeamManagementPage = (): JSX.Element => { ) : ( { return ( diff --git a/frontend/pages/hosts/ManageHostsPage/HostTableConfig.tsx b/frontend/pages/hosts/ManageHostsPage/HostTableConfig.tsx index dfaecd155a..f6236cceda 100644 --- a/frontend/pages/hosts/ManageHostsPage/HostTableConfig.tsx +++ b/frontend/pages/hosts/ManageHostsPage/HostTableConfig.tsx @@ -24,6 +24,7 @@ import { humanHostLastSeen, hostTeamName, } from "utilities/helpers"; +import { COLORS } from "styles/var/colors"; import { IDataColumn } from "interfaces/datatable_config"; import PATHS from "router/paths"; import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants"; @@ -171,7 +172,7 @@ const allHostTableHeaders: IDataColumn[] = [ @@ -355,7 +356,7 @@ const allHostTableHeaders: IDataColumn[] = [ diff --git a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx index d8badaf4c0..e638378ba5 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx @@ -23,8 +23,9 @@ import Button from "components/buttons/Button"; import TabsWrapper from "components/TabsWrapper"; import InfoBanner from "components/InfoBanner"; import Icon from "components/Icon/Icon"; -import { normalizeEmptyValues, wrapFleetHelper } from "utilities/helpers"; +import { normalizeEmptyValues } from "utilities/helpers"; import PATHS from "router/paths"; +import { DOCUMENT_TITLE_SUFFIX } from "utilities/constants"; import HostSummaryCard from "../cards/HostSummary"; import AboutCard from "../cards/About"; @@ -309,7 +310,7 @@ const DeviceUserPage = ({ // e.g., Rachel's Macbook Pro schedule details | Fleet for osquery document.title = `My device ${hostTab()} details | ${ host?.display_name || "Unknown host" - } | Fleet for osquery`; + } | ${DOCUMENT_TITLE_SUFFIX}`; }, [location.pathname, host]); const renderActionButtons = () => { diff --git a/frontend/pages/hosts/details/DeviceUserPage/_styles.scss b/frontend/pages/hosts/details/DeviceUserPage/_styles.scss index 2cba28bdfd..ac88dea247 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/_styles.scss +++ b/frontend/pages/hosts/details/DeviceUserPage/_styles.scss @@ -5,13 +5,9 @@ } .device-user { - display: flex; - flex-direction: column; justify-content: flex-start; padding-bottom: 50px; min-width: 0; - background-color: $ui-off-white; - gap: $pad-medium; .info-banner { &__cta { diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index e2c7af8ac4..41338e53f0 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -52,6 +52,7 @@ import { } from "utilities/helpers"; import permissions from "utilities/permissions"; import ScriptDetailsModal from "pages/DashboardPage/cards/ActivityFeed/components/ScriptDetailsModal"; +import { DOCUMENT_TITLE_SUFFIX } from "utilities/constants"; import HostSummaryCard from "../cards/HostSummary"; import AboutCard from "../cards/About"; @@ -62,7 +63,7 @@ import ScriptsCard from "../cards/Scripts"; import SoftwareCard from "../cards/Software"; import UsersCard from "../cards/Users"; import PoliciesCard from "../cards/Policies"; -import ScheduleCard from "../cards/Schedule"; +import QueriesCard from "../cards/Queries"; import PacksCard from "../cards/Packs"; import PolicyDetailsModal from "../cards/Policies/HostPoliciesTable/PolicyDetailsModal"; import UnenrollMdmModal from "./modals/UnenrollMdmModal"; @@ -300,6 +301,7 @@ const HostDetailsPage = ({ } setHostSoftware(returnedHost.software || []); setUsersState(returnedHost.users || []); + setSchedule(schedule); if (returnedHost.pack_stats) { const packStatsByType = returnedHost.pack_stats.reduce( ( @@ -319,7 +321,6 @@ const HostDetailsPage = ({ { packs: [], schedule: [] } ); setSchedule(packStatsByType.schedule); - setPacksState(packStatsByType.packs); } }, onError: (error) => handlePageError(error), @@ -360,7 +361,7 @@ const HostDetailsPage = ({ // e.g., Rachel's Macbook Pro schedule details | Fleet for osquery document.title = `Host ${hostTab()} details ${ host?.display_name ? `| ${host?.display_name} |` : "|" - } Fleet for osquery`; + } ${DOCUMENT_TITLE_SUFFIX}`; }, [location.pathname, host]); // Used for back to software pathname @@ -583,7 +584,7 @@ const HostDetailsPage = ({ ); }; - if (isLoadingHost) { + if (!host || isLoadingHost) { return ; } const failingPoliciesCount = host?.issues.failing_policies_count || 0; @@ -605,9 +606,9 @@ const HostDetailsPage = ({ pathname: PATHS.HOST_SOFTWARE(hostIdFromURL), }, { - name: "Schedule", - title: "schedule", - pathname: PATHS.HOST_SCHEDULE(hostIdFromURL), + name: "Queries", + title: "queries", + pathname: PATHS.HOST_QUERIES(hostIdFromURL), }, { name: ( @@ -673,7 +674,7 @@ const HostDetailsPage = ({ return ( -
+ <> - {canViewPacks && ( @@ -854,7 +859,7 @@ const HostDetailsPage = ({ onCancel={onCancelScriptDetailsModal} /> )} -
+
); }; diff --git a/frontend/pages/hosts/details/HostDetailsPage/_styles.scss b/frontend/pages/hosts/details/HostDetailsPage/_styles.scss index 209857d40e..5a5331f4e1 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/_styles.scss +++ b/frontend/pages/hosts/details/HostDetailsPage/_styles.scss @@ -1,11 +1,4 @@ .host-details { - background-color: $ui-off-white; - - &__wrapper { - display: grid; - gap: 1rem; - } - .component__tabs-wrapper { .react-tabs__tab { display: inline-flex; diff --git a/frontend/pages/hosts/details/HostDetailsPage/components/HostDetailsBanners/HostDetailsBanners.tsx b/frontend/pages/hosts/details/HostDetailsPage/components/HostDetailsBanners/HostDetailsBanners.tsx index e40759fd5b..e39878312f 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/components/HostDetailsBanners/HostDetailsBanners.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/components/HostDetailsBanners/HostDetailsBanners.tsx @@ -45,24 +45,27 @@ const HostDetailsBanners = ({ mdmName === "Fleet" && diskEncryptionStatus === "action_required"; - return ( -
- {showTurnOnMdmInfoBanner && ( - - To change settings and install software, ask the end user to follow - the Turn on MDM instructions on their{" "} - My device page. - - )} - {showDiskEncryptionUserActionRequired && ( - - Disk encryption: Requires action from the end user. Ask the end user - to follow Disk encryption instructions on their{" "} - My device page. - - )} -
- ); + if (showTurnOnMdmInfoBanner || showDiskEncryptionUserActionRequired) { + return ( +
+ {showTurnOnMdmInfoBanner && ( + + To change settings and install software, ask the end user to follow + the Turn on MDM instructions on their{" "} + My device page. + + )} + {showDiskEncryptionUserActionRequired && ( + + Disk encryption: Requires action from the end user. Ask the end user + to follow Disk encryption instructions on their{" "} + My device page. + + )} +
+ ); + } + return null; }; export default HostDetailsBanners; diff --git a/frontend/pages/hosts/details/HostQueryReport/HQRTable/HQRTable.tsx b/frontend/pages/hosts/details/HostQueryReport/HQRTable/HQRTable.tsx new file mode 100644 index 0000000000..9b882041a3 --- /dev/null +++ b/frontend/pages/hosts/details/HostQueryReport/HQRTable/HQRTable.tsx @@ -0,0 +1,174 @@ +import Button from "components/buttons/Button"; +import EmptyTable from "components/EmptyTable"; +import Icon from "components/Icon"; +import TableContainer from "components/TableContainer"; +import React, { useCallback, useState } from "react"; +import { Row } from "react-table"; +import { + generateCSVFilename, + generateCSVQueryResults, +} from "utilities/generate_csv"; +import FileSaver from "file-saver"; +import Spinner from "components/Spinner"; +import { HumanTimeDiffWithFleetLaunchCutoff } from "components/HumanTimeDiffWithDateTip"; +import generateColumnConfigs from "./HQRTableConfig"; + +const baseClass = "hqr-table"; + +interface IHQRTable { + queryName?: string; + queryDescription?: string; + hostName?: string; + rows: Record[]; + reportClipped?: boolean; + lastFetched?: string | null; // timestamp + onShowQuery: () => void; + isLoading: boolean; +} + +const DEFAULT_CSV_TITLE = "Host-Specific Query Report"; + +const HQRTable = ({ + queryName, + queryDescription, + hostName, + rows, + reportClipped, + lastFetched, + onShowQuery, + isLoading, +}: IHQRTable) => { + const [filteredResults, setFilteredResults] = useState([]); + + const columnConfigs = generateColumnConfigs(rows); + + const renderTableButtons = useCallback(() => { + const onExportQueryResults = (evt: React.MouseEvent) => { + evt.preventDefault(); + FileSaver.saveAs( + generateCSVQueryResults( + filteredResults, + generateCSVFilename( + queryName && hostName + ? `'${queryName}' query report results for host '${hostName}'` + : DEFAULT_CSV_TITLE + ), + columnConfigs + ) + ); + }; + return ( +
+ + +
+ ); + }, [onShowQuery, filteredResults, queryName, hostName, columnConfigs]); + + const renderEmptyState = useCallback(() => { + // rows.length === 0 + + if (!lastFetched) { + // collecting results + return ( + + ); + } + if (reportClipped) { + return ( + + ); + } + return ( + // nothing to report + + ); + }, [lastFetched, hostName, reportClipped]); + + const renderCount = useCallback(() => { + const count = filteredResults.length; + return ( +
+ {`${count} result${count === 1 ? "" : "s"}`} + + Last fetched{" "} + + +
+ ); + }, [filteredResults.length, lastFetched]); + + const renderTableInfo = useCallback( + () => ( +
+

{queryName}

+

{queryDescription}

+
+ ), + [queryDescription, queryName] + ); + + if (isLoading) { + return ; + } + return ( +
+ {renderTableInfo()} + {rows.length === 0 ? ( + renderEmptyState() + ) : ( + null} + defaultSortHeader={columnConfigs[0].title} + defaultSortDirection="asc" + /> + )} +
+ ); +}; + +export default HQRTable; diff --git a/frontend/pages/hosts/details/HostQueryReport/HQRTable/HQRTableConfig.tsx b/frontend/pages/hosts/details/HostQueryReport/HQRTable/HQRTableConfig.tsx new file mode 100644 index 0000000000..1245f15ba1 --- /dev/null +++ b/frontend/pages/hosts/details/HostQueryReport/HQRTable/HQRTableConfig.tsx @@ -0,0 +1,65 @@ +import DefaultColumnFilter from "components/TableContainer/DataTable/DefaultColumnFilter"; +import HeaderCell from "components/TableContainer/DataTable/HeaderCell"; +import React from "react"; + +import { + CellProps, + ColumnInstance, + ColumnInterface, + HeaderProps, + TableInstance, +} from "react-table"; +import { + getUniqueColumnNamesFromRows, + humanHostLastSeen, + internallyTruncateText, +} from "utilities/helpers"; + +type IHeaderProps = HeaderProps & { + column: ColumnInstance & IDataColumn; +}; + +type ICellProps = CellProps; + +interface IDataColumn extends ColumnInterface { + title?: string; + accessor: string; +} + +const generateColumnConfigs = (rows: Record[]) => + // casting necessary because of loose typing of below method + // see note there for more details + (getUniqueColumnNamesFromRows(rows) as string[]).map((colName) => { + return { + id: colName, + title: colName, + Header: (headerProps: IHeaderProps) => ( + + ), + accessor: colName, + Cell: (cellProps: ICellProps) => { + // Sorts chronologically by date, but UI displays readable last fetched + if (cellProps.column.id === "last_fetched") { + return humanHostLastSeen(cellProps?.cell?.value); + } + // truncate columns longer than 300 characters + const val = cellProps?.cell?.value; + return !!val?.length && val.length > 300 + ? internallyTruncateText(val) + : val ?? null; + }, + Filter: DefaultColumnFilter, // Component hides filter for last_fetched + filterType: "text", + disableSortBy: false, + }; + }); + +export default generateColumnConfigs; diff --git a/frontend/pages/hosts/details/HostQueryReport/HQRTable/_styles.scss b/frontend/pages/hosts/details/HostQueryReport/HQRTable/_styles.scss new file mode 100644 index 0000000000..9fb768a1ee --- /dev/null +++ b/frontend/pages/hosts/details/HostQueryReport/HQRTable/_styles.scss @@ -0,0 +1,55 @@ +.hqr-table { + gap: $pad-medium; + &__results-count-and-last-fetched { + display: flex; + align-items: baseline; + gap: $pad-small; + + .last-fetched { + font-weight: initial; + @include grey-text; + } + } + &__results-cta { + display: flex; + gap: $pad-medium; + .button { + height: auto; + } + } + + &__export-btn { + .children-wrapper { + align-self: flex-end; + } + .icon { + display: initial; + } + } + + &__query-info { + margin-top: $pad-xsmall; + display: flex; + flex-direction: column; + gap: 0.8rem; + + h2 { + font-size: $medium; + font-weight: $bold; + margin: 0; + } + h3 { + font-size: $x-small; + font-weight: $regular; + margin: 0; + } + } + + .data-table { + overflow-x: scroll; + } + + .empty-table__container { + margin: $pad-large auto 48px; + } +} diff --git a/frontend/pages/hosts/details/HostQueryReport/HQRTable/index.ts b/frontend/pages/hosts/details/HostQueryReport/HQRTable/index.ts new file mode 100644 index 0000000000..62d454bdc2 --- /dev/null +++ b/frontend/pages/hosts/details/HostQueryReport/HQRTable/index.ts @@ -0,0 +1 @@ +export { default } from "./HQRTable"; diff --git a/frontend/pages/hosts/details/HostQueryReport/HostQueryReport.tsx b/frontend/pages/hosts/details/HostQueryReport/HostQueryReport.tsx new file mode 100644 index 0000000000..25edd76717 --- /dev/null +++ b/frontend/pages/hosts/details/HostQueryReport/HostQueryReport.tsx @@ -0,0 +1,160 @@ +import BackLink from "components/BackLink"; +import Icon from "components/Icon"; +import MainContent from "components/MainContent"; +import ShowQueryModal from "components/modals/ShowQueryModal"; +import Spinner from "components/Spinner"; +import { AppContext } from "context/app"; +import { + IGetQueryResponse, + ISchedulableQuery, +} from "interfaces/schedulable_query"; +import React, { useCallback, useContext, useState } from "react"; +import { useQuery } from "react-query"; +import { browserHistory, InjectedRouter, Link } from "react-router"; +import { Params } from "react-router/lib/Router"; +import PATHS from "router/paths"; +import hqrAPI, { IGetHQRResponse } from "services/entities/host_query_report"; +import queryAPI from "services/entities/queries"; +import { DOCUMENT_TITLE_SUFFIX } from "utilities/constants"; +import HQRTable from "./HQRTable"; + +const baseClass = "host-query-report"; + +interface IHostQueryReportProps { + router: InjectedRouter; + params: Params; +} + +const HostQueryReport = ({ + router, + params: { host_id, query_id }, +}: IHostQueryReportProps) => { + const { config } = useContext(AppContext); + const globalReportsDisabled = config?.server_settings.query_reports_disabled; + const hostId = Number(host_id); + const queryId = Number(query_id); + + if (globalReportsDisabled) { + router.push(PATHS.HOST_QUERIES(hostId)); + } + + const [showQuery, setShowQuery] = useState(false); + + const { + data: hqrResponse, + isLoading: hqrLoading, + error: hqrError, + } = useQuery( + [hostId, queryId], + () => hqrAPI.load(hostId, queryId), + { + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + } + ); + + const { + isLoading: queryLoading, + data: queryResponse, + error: queryError, + } = useQuery( + ["query", queryId], + () => queryAPI.load(queryId), + + { + select: (data) => data.query, + enabled: !!queryId, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + } + ); + + const isLoading = queryLoading || hqrLoading; + + const { + host_name: hostName, + report_clipped: reportClipped, + last_fetched: lastFetched, + results, + } = hqrResponse || {}; + + // API response is nested this way to mirror that of the full Query Reports response (IQueryReport) + const rows = results?.map((row) => row.columns) ?? []; + + const { + name: queryName, + description: queryDescription, + query: querySQL, + discard_data: queryDiscardData, + } = queryResponse || {}; + + // TODO - finalize local setting reroute conditions + // previous reroute can be done before API call, not this one, hence 2 + if (queryDiscardData) { + router.push(PATHS.HOST_QUERIES(hostId)); + } + + document.title = `Host query report | ${queryName} | ${hostName} | ${DOCUMENT_TITLE_SUFFIX}`; + + const HQRHeader = useCallback(() => { + const fullReportPath = PATHS.QUERY_DETAILS(queryId); + return ( +
+
+ +
+
+ {!hqrError &&

{hostName}

} + { + browserHistory.push(fullReportPath); + }} + className={`${baseClass}__direction-link`} + > + <> + View full query report + + + +
+
+ ); + }, [queryId, hostId, hqrError, hostName]); + + return ( + + {isLoading ? ( + + ) : ( + <> + + setShowQuery(true)} + isLoading={false} + /> + {showQuery && ( + setShowQuery(false)} + /> + )} + + )} + + ); +}; + +export default HostQueryReport; diff --git a/frontend/pages/hosts/details/HostQueryReport/_styles.scss b/frontend/pages/hosts/details/HostQueryReport/_styles.scss new file mode 100644 index 0000000000..5eb09d06a9 --- /dev/null +++ b/frontend/pages/hosts/details/HostQueryReport/_styles.scss @@ -0,0 +1,24 @@ +.host-query-report { + display: flex; + flex-direction: column; + gap: $pad-large; + @include color-contrasted-sections; + + h1 { + font-weight: $xbold; + } + &__header { + display: flex; + flex-direction: column; + gap: $pad-xlarge; + + &__row2 { + display: flex; + align-items: center; + justify-content: space-between; + } + } + &__direction-link { + @include direction-link; + } +} diff --git a/frontend/pages/hosts/details/HostQueryReport/index.ts b/frontend/pages/hosts/details/HostQueryReport/index.ts new file mode 100644 index 0000000000..0c099a2726 --- /dev/null +++ b/frontend/pages/hosts/details/HostQueryReport/index.ts @@ -0,0 +1 @@ +export { default } from "./HostQueryReport"; diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/OSSettingStatusCell.tsx b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/OSSettingStatusCell.tsx index 34f91caeac..46b0063fe1 100644 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/OSSettingStatusCell.tsx +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/OSSettingStatusCell.tsx @@ -8,6 +8,7 @@ import { FLEET_FILEVAULT_PROFILE_DISPLAY_NAME, ProfileOperationType, } from "interfaces/mdm"; +import { COLORS } from "styles/var/colors"; import { isMdmProfileStatus, @@ -71,7 +72,7 @@ const OSSettingStatusCell = ({ diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTable.tsx b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTable.tsx index b97715459d..176d0c12e9 100644 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTable.tsx +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTable.tsx @@ -15,7 +15,7 @@ const OSSettingsTable = ({ tableData }: IOSSettingsTableProps) => { @@ -81,7 +82,7 @@ const ProfileStatusIndicator = ({ diff --git a/frontend/pages/hosts/details/_styles.scss b/frontend/pages/hosts/details/_styles.scss index 7916400468..4db893e04d 100644 --- a/frontend/pages/hosts/details/_styles.scss +++ b/frontend/pages/hosts/details/_styles.scss @@ -1,20 +1,16 @@ .host-details, .device-user { + display: flex; + flex-direction: column; + gap: $pad-medium; + + @include color-contrasted-sections; .header { flex: 100%; display: flex; flex-direction: column; } .section { - flex: 100%; - display: flex; - flex-direction: column; - background-color: $core-white; - border-radius: 16px; - border: 1px solid $ui-fleet-black-10; - padding: $pad-xxlarge; - box-shadow: 0px 3px 0px rgba(226, 228, 234, 0.4); - &__header { font-size: $medium; font-weight: $bold; @@ -302,4 +298,11 @@ align-items: center; } } + + .empty-table { + &__container { + margin: 0 0 $pad-xxlarge 0; + min-height: initial; + } + } } diff --git a/frontend/pages/hosts/details/cards/About/About.tsx b/frontend/pages/hosts/details/cards/About/About.tsx index 5c340a28e0..f0528df878 100644 --- a/frontend/pages/hosts/details/cards/About/About.tsx +++ b/frontend/pages/hosts/details/cards/About/About.tsx @@ -15,6 +15,7 @@ import { DEFAULT_EMPTY_CELL_VALUE, MDM_STATUS_TOOLTIP, } from "utilities/constants"; +import { COLORS } from "styles/var/colors"; const getDeviceUserTipContent = (deviceMapping: IDeviceUser[]) => { if (deviceMapping.length === 0) { @@ -60,7 +61,7 @@ const About = ({ You can’t fetch data from
an offline host. @@ -168,7 +169,7 @@ const HostSummary = ({ @@ -340,10 +341,10 @@ const HostSummary = ({ : titleData.display_name || DEFAULT_EMPTY_CELL_VALUE} -

+

{"Last fetched"} {lastFetched}   -

+
{renderRefetch()}
diff --git a/frontend/pages/hosts/details/cards/HostSummary/OSSettingsIndicator/OSSettingsIndicator.tsx b/frontend/pages/hosts/details/cards/HostSummary/OSSettingsIndicator/OSSettingsIndicator.tsx index 9c23243957..6f90c372ab 100644 --- a/frontend/pages/hosts/details/cards/HostSummary/OSSettingsIndicator/OSSettingsIndicator.tsx +++ b/frontend/pages/hosts/details/cards/HostSummary/OSSettingsIndicator/OSSettingsIndicator.tsx @@ -6,6 +6,7 @@ import { IHostMdmProfile, MdmProfileStatus } from "interfaces/mdm"; import Icon from "components/Icon"; import Button from "components/buttons/Button"; import { IconNames } from "components/icons"; +import { COLORS } from "styles/var/colors"; const baseClass = "os-settings-indicator"; @@ -145,7 +146,7 @@ const OSSettingsIndicator = ({ diff --git a/frontend/pages/hosts/details/cards/MunkiIssues/MunkiIssues.tsx b/frontend/pages/hosts/details/cards/MunkiIssues/MunkiIssues.tsx index e62cda16c9..103fb4c9eb 100644 --- a/frontend/pages/hosts/details/cards/MunkiIssues/MunkiIssues.tsx +++ b/frontend/pages/hosts/details/cards/MunkiIssues/MunkiIssues.tsx @@ -30,7 +30,7 @@ const MunkiIssuesTable = ({ {munkiIssues?.length ? (
{ {!!pack.query_stats.length && (
null} diff --git a/frontend/pages/hosts/details/cards/Policies/Policies.tsx b/frontend/pages/hosts/details/cards/Policies/Policies.tsx index f2ec6662b0..993e5e0e22 100644 --- a/frontend/pages/hosts/details/cards/Policies/Policies.tsx +++ b/frontend/pages/hosts/details/cards/Policies/Policies.tsx @@ -86,7 +86,7 @@ const Policies = ({ )} { + const renderEmptyQueriesTab = () => { + if (isChromeOSHost) { + return ( + + Interested in collecting data from your Chromebooks? + + + } + /> + ); + } + return ( + + Expecting to see queries? Try selecting Refetch to ask this + host to report fresh vitals. + + } + /> + ); + }; + + const onSelectSingleRow = useCallback( + (row: IHostQueriesRowProps) => { + const { id: queryId, should_link_to_hqr } = row.original; + + if (!hostId || !queryId || !should_link_to_hqr || queryReportsDisabled) { + return; + } + router.push(`${PATHS.HOST_QUERY_REPORT(hostId, queryId)}`); + }, + [hostId, queryReportsDisabled, router] + ); + + const tableData = useMemo(() => generateDataSet(schedule ?? []), [schedule]); + + const columnConfigs = useMemo( + () => generateColumnConfigs(queryReportsDisabled), + [queryReportsDisabled] + ); + + return ( +
+

Queries

+ {!schedule || !schedule.length || isChromeOSHost ? ( + renderEmptyQueriesTab() + ) : ( +
+ null} + resultsTitle="queries" + defaultSortHeader="query_name" + defaultSortDirection="asc" + showMarkAllPages={false} + isAllPagesSelected={false} + emptyComponent={() => <>} + disablePagination + disableCount + disableMultiRowSelect + isLoading={false} // loading state handled at parent level + {...{ onSelectSingleRow }} + /> +
+ )} +
+ ); +}; + +export default HostQueries; diff --git a/frontend/pages/hosts/details/cards/Queries/HostQueriesTableConfig.tsx b/frontend/pages/hosts/details/cards/Queries/HostQueriesTableConfig.tsx new file mode 100644 index 0000000000..6630ed0897 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Queries/HostQueriesTableConfig.tsx @@ -0,0 +1,166 @@ +import React from "react"; + +import { IQueryStats } from "interfaces/query_stats"; +import { performanceIndicator } from "utilities/helpers"; + +import TextCell from "components/TableContainer/DataTable/TextCell"; +import PillCell from "components/TableContainer/DataTable/PillCell"; +import TooltipWrapper from "components/TooltipWrapper"; +import ReportUpdatedCell from "pages/hosts/details/cards/Queries/ReportUpdatedCell"; +import Icon from "components/Icon"; + +interface IHostQueriesTableData extends Partial { + performance: { indicator: string; id: number }; + should_link_to_hqr: boolean; +} +interface IHeaderProps { + column: { + title: string; + isSortedDesc: boolean; + }; +} + +interface IRowProps { + row: { + original: IHostQueriesTableData; + }; +} + +interface ICellProps extends IRowProps { + cell: { + value: string | number | boolean; + }; +} + +interface IPillCellProps extends IRowProps { + cell: { + value: { + indicator: string; + id: number; + }; + }; +} + +interface IDataColumn { + title?: string; + Header: ((props: IHeaderProps) => JSX.Element) | string; + accessor: string; + Cell: + | ((props: ICellProps) => JSX.Element) + | ((props: IPillCellProps) => JSX.Element); + disableHidden?: boolean; + disableSortBy?: boolean; +} + +// NOTE: cellProps come from react-table +// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties +const generateColumnConfigs = ( + queryReportsDisabled?: boolean +): IDataColumn[] => { + const cols: IDataColumn[] = [ + { + title: "Query", + Header: "Query", + disableSortBy: true, + accessor: "query_name", + Cell: (cellProps: ICellProps) => ( + + ), + }, + { + Header: () => { + return ( + + This is the performance
+ impact on this host. + + } + > + Performance impact +
+ ); + }, + disableSortBy: true, + accessor: "performance", + Cell: (cellProps: IPillCellProps) => { + const baseClass = "performance-cell"; + return ( + + + {!queryReportsDisabled && + cellProps.row.original.should_link_to_hqr && ( + + )} + + ); + }, + }, + ]; + + // include the Report updated column if query reports are globally enabled + if (!queryReportsDisabled) { + cols.push({ + Header: "Report updated", + disableSortBy: true, + accessor: "last_fetched", // tbd - may change + Cell: (cellProps: ICellProps) => ( + + ), + }); + } + return cols; +}; + +const enhanceScheduleData = ( + query_stats: IQueryStats[] +): IHostQueriesTableData[] => { + return Object.values(query_stats).map((query) => { + const { + user_time, + system_time, + executions, + query_name, + scheduled_query_id, + last_fetched, + interval, + discard_data, + automations_enabled, + } = query; + const scheduledQueryPerformance = { + user_time_p50: user_time, + system_time_p50: system_time, + total_executions: executions, + }; + return { + query_name, + id: scheduled_query_id, + performance: { + indicator: performanceIndicator(scheduledQueryPerformance), + id: scheduled_query_id, + }, + last_fetched, + interval, + discard_data, + automations_enabled, + should_link_to_hqr: !!last_fetched || (!!interval && !discard_data), + }; + }); +}; + +const generateDataSet = ( + query_stats: IQueryStats[] +): IHostQueriesTableData[] => { + return query_stats ? enhanceScheduleData(query_stats) : []; +}; + +export { generateColumnConfigs, generateDataSet }; diff --git a/frontend/pages/hosts/details/cards/Queries/ReportUpdatedCell/ReportUpdatedCell.tests.tsx b/frontend/pages/hosts/details/cards/Queries/ReportUpdatedCell/ReportUpdatedCell.tests.tsx new file mode 100644 index 0000000000..e9865c7328 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Queries/ReportUpdatedCell/ReportUpdatedCell.tests.tsx @@ -0,0 +1,72 @@ +import React from "react"; + +import { render, screen } from "@testing-library/react"; + +import ReportUpdatedCell from "./ReportUpdatedCell"; + +describe("ReportUpdatedCell component", () => { + it("Renders '---' with tooltip and no link when run on an interval with discard data and automations enabled", () => { + render( + + ); + + expect(screen.getByText(/---/)).toBeInTheDocument(); + expect(screen.getByText(/Results from this query/)).toBeInTheDocument(); + expect(screen.queryByText(/View report/)).toBeNull(); + }); + + it("Renders 'Never with tooltip and link to report when run on an interval with discard data off and no last_fetched time", () => { + render( + + ); + + expect(screen.getByText(/Never/)).toBeInTheDocument(); + expect(screen.getByText(/This query has not run/)).toBeInTheDocument(); + expect(screen.getByText(/View report/)).toBeInTheDocument(); + }); + + it("Renders a last-updated timestamp with tooltip and link to report when a last_fetched date is present", () => { + render( + + ); + + expect( + screen.getByText(/\d\d\/\d\d\/\d\d\d\d, \d{1,2}:\d{1,2}:\d{1,2}( AM|PM)?/) + ).toBeInTheDocument(); + expect(screen.getByText(/\d+ days ago/)).toBeInTheDocument(); + expect(screen.getByText(/View report/)).toBeInTheDocument(); + }); + it("Renders a last-updated timestamp with tooltip and link to report when a last_fetched date is present but not currently running an interval", () => { + render( + + ); + + expect( + screen.getByText(/\d\d\/\d\d\/\d\d\d\d, \d{1,2}:\d{1,2}:\d{1,2}( AM|PM)?/) + ).toBeInTheDocument(); + expect(screen.getByText(/\d+ days ago/)).toBeInTheDocument(); + expect(screen.getByText(/View report/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/pages/hosts/details/cards/Queries/ReportUpdatedCell/ReportUpdatedCell.tsx b/frontend/pages/hosts/details/cards/Queries/ReportUpdatedCell/ReportUpdatedCell.tsx new file mode 100644 index 0000000000..8bdec1eab0 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Queries/ReportUpdatedCell/ReportUpdatedCell.tsx @@ -0,0 +1,111 @@ +import React from "react"; + +import { HumanTimeDiffWithFleetLaunchCutoff } from "components/HumanTimeDiffWithDateTip"; +import { uniqueId } from "lodash"; +import ReactTooltip from "react-tooltip"; +import { COLORS } from "styles/var/colors"; +import Icon from "components/Icon"; +import TextCell from "components/TableContainer/DataTable/TextCell"; + +const baseClass = "report-updated-cell"; + +interface IReportUpdatedCell { + last_fetched?: string | null; + interval?: number; + discard_data?: boolean; + automations_enabled?: boolean; + should_link_to_hqr?: boolean; +} + +const ReportUpdatedCell = ({ + last_fetched, + interval, + discard_data, + automations_enabled, + should_link_to_hqr, +}: IReportUpdatedCell) => { + const renderCellValue = () => { + // if this query doesn't have an interval, it either has a stored report from previous runs + // and will link to that report, or won't be included in this data in the first place. + if (interval) { + if (discard_data && automations_enabled) { + // this is also the only case where the row is NOT clickable with a link to the host's HQR + // query runs, sends results to a logging dest, doesn't cache + return ( + + Results from this query are not reported in Fleet. +
+ Data is being sent to your log destination. + + } + /> + ); + } + + // Query is scheduled to run on host, but hasn't yet + if (!last_fetched) { + const tipId = uniqueId(); + return ( + ( + <> + + {val} + + + This query has not run on this host. + + + )} + greyed + classes={`${baseClass}__value`} + /> + ); + } + } + + // render with link to cached results (link handled by clickable parent row) + return ( + <> + + + ); + }; + + return ( + + {renderCellValue()} + {should_link_to_hqr && ( + // actual link functionality handled by clickable parent row + + View report + + + )} + + ); +}; + +export default ReportUpdatedCell; diff --git a/frontend/pages/hosts/details/cards/Queries/ReportUpdatedCell/_styles.scss b/frontend/pages/hosts/details/cards/Queries/ReportUpdatedCell/_styles.scss new file mode 100644 index 0000000000..ef4296bb5c --- /dev/null +++ b/frontend/pages/hosts/details/cards/Queries/ReportUpdatedCell/_styles.scss @@ -0,0 +1,17 @@ +.report-updated-cell { + @include cell-with-link; + + &__value { + min-width: initial; + } + + &__link { + @include table-link; + } + + &__link-text { + // hover state of parent tr sets opacity to 1 + opacity: 0; + transition: opacity 250ms; + } +} diff --git a/frontend/pages/hosts/details/cards/Queries/ReportUpdatedCell/index.ts b/frontend/pages/hosts/details/cards/Queries/ReportUpdatedCell/index.ts new file mode 100644 index 0000000000..1fd58ec49c --- /dev/null +++ b/frontend/pages/hosts/details/cards/Queries/ReportUpdatedCell/index.ts @@ -0,0 +1 @@ +export { default } from "./ReportUpdatedCell"; diff --git a/frontend/pages/hosts/details/cards/Queries/_styles.scss b/frontend/pages/hosts/details/cards/Queries/_styles.scss new file mode 100644 index 0000000000..bffcdac122 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Queries/_styles.scss @@ -0,0 +1,60 @@ +.section--host-queries { + margin-top: $pad-medium; + .section__header { + margin-bottom: $pad-medium; + } + .table-container__header { + display: none; + } + .data-table-block { + .data-table__table { + thead { + .query_name__header { + width: $col-lg; + } + .last_fetched__header { + display: table-cell; + } + @media (max-width: $break-md) { + .last_fetched__header { + display: none; + width: 0; + } + } + } + tbody { + tr { + .query_name__cell { + width: $col-lg; + } + .last_fetched__cell { + display: table-cell; + } + .performance-cell { + @include cell-with-link; + &__link-icon { + display: none; + width: 0; + } + } + &:hover { + .report-updated-cell__link-text { + opacity: 1; + } + } + @media (max-width: $break-md) { + .last_fetched__cell { + display: none; + width: 0; + } + .performance-cell__link-icon { + display: inline-flex; + align-self: center; + width: initial; + } + } + } + } + } + } +} diff --git a/frontend/pages/hosts/details/cards/Queries/index.ts b/frontend/pages/hosts/details/cards/Queries/index.ts new file mode 100644 index 0000000000..b6536c4c04 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Queries/index.ts @@ -0,0 +1 @@ +export { default } from "./HostQueries"; diff --git a/frontend/pages/hosts/details/cards/Schedule/Schedule.tsx b/frontend/pages/hosts/details/cards/Schedule/Schedule.tsx deleted file mode 100644 index 1c37493510..0000000000 --- a/frontend/pages/hosts/details/cards/Schedule/Schedule.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from "react"; - -import { IQueryStats } from "interfaces/query_stats"; -import TableContainer from "components/TableContainer"; -import EmptyTable from "components/EmptyTable"; -import CustomLink from "components/CustomLink"; - -import { generateTableHeaders, generateDataSet } from "./ScheduleTableConfig"; - -const baseClass = "schedule"; - -interface IScheduleProps { - schedule?: IQueryStats[]; - isChromeOSHost: boolean; - isLoading: boolean; -} - -const Schedule = ({ - schedule, - isChromeOSHost, - isLoading, -}: IScheduleProps): JSX.Element => { - const wrapperClassName = `${baseClass}__pack-table`; - const tableHeaders = generateTableHeaders(); - - const renderEmptyScheduleTab = () => { - if (isChromeOSHost) { - return ( - - Interested in collecting data from your Chromebooks? - - - } - /> - ); - } - return ( - - ); - }; - - return ( -
-

Schedule

- {!schedule || !schedule.length || isChromeOSHost ? ( - renderEmptyScheduleTab() - ) : ( -
- null} - resultsTitle={"queries"} - defaultSortHeader={"scheduled_query_name"} - defaultSortDirection={"asc"} - showMarkAllPages={false} - isAllPagesSelected={false} - emptyComponent={() => <>} - disablePagination - disableCount - /> -
- )} -
- ); -}; - -export default Schedule; diff --git a/frontend/pages/hosts/details/cards/Schedule/ScheduleTableConfig.tsx b/frontend/pages/hosts/details/cards/Schedule/ScheduleTableConfig.tsx deleted file mode 100644 index 1bada23c35..0000000000 --- a/frontend/pages/hosts/details/cards/Schedule/ScheduleTableConfig.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import React from "react"; - -import { IQueryStats } from "interfaces/query_stats"; -import { performanceIndicator, secondsToDhms } from "utilities/helpers"; - -import TextCell from "components/TableContainer/DataTable/TextCell"; -import PillCell from "components/TableContainer/DataTable/PillCell"; -import TooltipWrapper from "components/TooltipWrapper"; - -interface IHeaderProps { - column: { - title: string; - isSortedDesc: boolean; - }; -} - -interface IRowProps { - row: { - original: IQueryStats; - }; -} - -interface ICellProps extends IRowProps { - cell: { - value: string | number | boolean; - }; -} - -interface IPillCellProps extends IRowProps { - cell: { - value: { - indicator: string; - id: number; - }; - }; -} - -interface IDataColumn { - title?: string; - Header: ((props: IHeaderProps) => JSX.Element) | string; - accessor: string; - Cell: - | ((props: ICellProps) => JSX.Element) - | ((props: IPillCellProps) => JSX.Element); - disableHidden?: boolean; - disableSortBy?: boolean; -} - -interface IScheduleTable extends Partial { - frequency: string; - performance: { indicator: string; id: number }; -} - -// NOTE: cellProps come from react-table -// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties -const generateTableHeaders = (): IDataColumn[] => { - return [ - { - title: "Query", - Header: "Query", - disableSortBy: true, - accessor: "query_name", - Cell: (cellProps: ICellProps) => ( - - ), - }, - { - title: "Frequency", - Header: "Frequency", - disableSortBy: true, - accessor: "frequency", - Cell: (cellProps: ICellProps) => ( - - ), - }, - { - Header: () => { - return ( - - This is the performance
- impact on this host. - - } - > - Performance impact -
- ); - }, - disableSortBy: true, - accessor: "performance", - Cell: (cellProps: IPillCellProps) => ( - - ), - }, - ]; -}; - -const enhanceScheduleData = (query_stats: IQueryStats[]): IScheduleTable[] => { - return Object.values(query_stats).map((query) => { - const scheduledQueryPerformance = { - user_time_p50: query.user_time, - system_time_p50: query.system_time, - total_executions: query.executions, - }; - return { - query_name: query.query_name, - frequency: secondsToDhms(query.interval), - performance: { - indicator: performanceIndicator(scheduledQueryPerformance), - id: query.scheduled_query_id, - }, - }; - }); -}; - -const generateDataSet = (query_stats: IQueryStats[]): IScheduleTable[] => { - if (!query_stats) { - return query_stats; - } - - return [...enhanceScheduleData(query_stats)]; -}; - -export { generateTableHeaders, generateDataSet }; diff --git a/frontend/pages/hosts/details/cards/Schedule/_styles.scss b/frontend/pages/hosts/details/cards/Schedule/_styles.scss deleted file mode 100644 index 4841674726..0000000000 --- a/frontend/pages/hosts/details/cards/Schedule/_styles.scss +++ /dev/null @@ -1,29 +0,0 @@ -.section--schedule { - margin-top: $pad-medium; - .section__header { - margin-bottom: $pad-medium; - } - .table-container__header { - display: none; - } - .data-table-block { - .data-table__table { - thead { - .query_name__header { - width: $col-lg; - } - .frequency__header { - width: $col-md; - } - } - tbody { - .query_name__cell { - width: $col-lg; - } - .frequency__cell { - width: $col-md; - } - } - } - } -} diff --git a/frontend/pages/hosts/details/cards/Schedule/index.ts b/frontend/pages/hosts/details/cards/Schedule/index.ts deleted file mode 100644 index 39250f5640..0000000000 --- a/frontend/pages/hosts/details/cards/Schedule/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./Schedule"; diff --git a/frontend/pages/hosts/details/cards/Scripts/Scripts.tsx b/frontend/pages/hosts/details/cards/Scripts/Scripts.tsx index 10e5169cd2..38505040c8 100644 --- a/frontend/pages/hosts/details/cards/Scripts/Scripts.tsx +++ b/frontend/pages/hosts/details/cards/Scripts/Scripts.tsx @@ -113,7 +113,7 @@ const Scripts = ({ emptyComponent={() => <>} showMarkAllPages={false} isAllPagesSelected={false} - columns={scriptColumnConfigs} + columnConfigs={scriptColumnConfigs} data={data} isLoading={isLoadingScriptData} onQueryChange={onQueryChange} diff --git a/frontend/pages/hosts/details/cards/Software/Software.tsx b/frontend/pages/hosts/details/cards/Software/Software.tsx index 05e3cf8a3a..00aae4e5ab 100644 --- a/frontend/pages/hosts/details/cards/Software/Software.tsx +++ b/frontend/pages/hosts/details/cards/Software/Software.tsx @@ -221,7 +221,7 @@ const SoftwareTable = ({
@@ -333,7 +333,7 @@ export const generateSoftwareTableHeaders = ({ Users

{users?.length ? ( - )} for this team's hosts.`} + tooltipContent={ + <> + "All teams" policies are checked +
+ for this team's hosts. + + } onClick={toggleShowInheritedPolicies} /> )} diff --git a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tsx b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tsx index abbfa69540..8b0285cfb2 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tsx @@ -7,7 +7,6 @@ import { ITeamSummary } from "interfaces/team"; import { IEmptyTableProps } from "interfaces/empty_table"; import Button from "components/buttons/Button"; -import Spinner from "components/Spinner"; import TableContainer from "components/TableContainer"; import { ITableQueryData } from "components/TableContainer/TableContainer"; import EmptyTable from "components/EmptyTable"; @@ -139,7 +138,7 @@ const PoliciesTable = ({ > @@ -238,7 +238,7 @@ const generateTableHeaders = ( diff --git a/frontend/pages/policies/PolicyPage/PolicyPage.tsx b/frontend/pages/policies/PolicyPage/PolicyPage.tsx index 6c44c1463b..8b6e9a4747 100644 --- a/frontend/pages/policies/PolicyPage/PolicyPage.tsx +++ b/frontend/pages/policies/PolicyPage/PolicyPage.tsx @@ -19,7 +19,7 @@ import globalPoliciesAPI from "services/entities/global_policies"; import teamPoliciesAPI from "services/entities/team_policies"; import hostAPI from "services/entities/hosts"; import statusAPI from "services/entities/status"; -import { LIVE_POLICY_STEPS } from "utilities/constants"; +import { DOCUMENT_TITLE_SUFFIX, LIVE_POLICY_STEPS } from "utilities/constants"; import QuerySidePanel from "components/side_panels/QuerySidePanel"; import QueryEditor from "pages/policies/PolicyPage/screens/QueryEditor"; @@ -207,7 +207,7 @@ const PolicyPage = ({ // Updates title that shows up on browser tabs useEffect(() => { // e.g., Policy details | Antivirus healthy (Linux) | Fleet for osquery - document.title = `Policy details | ${storedPolicy?.name} | Fleet for osquery`; + document.title = `Policy details | ${storedPolicy?.name} | ${DOCUMENT_TITLE_SUFFIX}`; }, [location.pathname, storedPolicy?.name]); useEffect(() => { diff --git a/frontend/pages/policies/PolicyPage/components/PolicyQueriesErrorsTable/PolicyQueriesErrorsTable.tsx b/frontend/pages/policies/PolicyPage/components/PolicyQueriesErrorsTable/PolicyQueriesErrorsTable.tsx index 6ee2beab49..e2532a2d8a 100644 --- a/frontend/pages/policies/PolicyPage/components/PolicyQueriesErrorsTable/PolicyQueriesErrorsTable.tsx +++ b/frontend/pages/policies/PolicyPage/components/PolicyQueriesErrorsTable/PolicyQueriesErrorsTable.tsx @@ -40,7 +40,7 @@ const PoliciesTable = ({ > Select the platform(s) this
diff --git a/frontend/pages/queries/ManageQueriesPage/ManageQueriesPage.tsx b/frontend/pages/queries/ManageQueriesPage/ManageQueriesPage.tsx index 80f6caabb5..76c8b17303 100644 --- a/frontend/pages/queries/ManageQueriesPage/ManageQueriesPage.tsx +++ b/frontend/pages/queries/ManageQueriesPage/ManageQueriesPage.tsx @@ -319,8 +319,12 @@ const ManageQueriesPage = ({ inheritedQueryCount === 1 ? "y" : "ies" }`} caretPosition={"before"} - tooltipHtml={ - 'Queries from the "All teams"
schedule run on this team’s hosts.' + tooltipContent={ + <> + Queries from the "All teams" +
+ schedule run on this team's hosts. + } onClick={() => { setShowInheritedQueries(!showInheritedQueries); diff --git a/frontend/pages/queries/ManageQueriesPage/_styles.scss b/frontend/pages/queries/ManageQueriesPage/_styles.scss index ce18192409..c6a8a0086a 100644 --- a/frontend/pages/queries/ManageQueriesPage/_styles.scss +++ b/frontend/pages/queries/ManageQueriesPage/_styles.scss @@ -98,7 +98,7 @@ .data-table-block { .data-table { &__wrapper { - overflow-x: scroll; + overflow-x: auto; overflow-y: hidden; } &__table { @@ -192,4 +192,9 @@ } } } + .reveal-button { + .component__tooltip-wrapper__underline { + position: initial; + } + } } diff --git a/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTable.tsx b/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTable.tsx index 033aa3c373..410109fd3c 100644 --- a/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTable.tsx +++ b/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTable.tsx @@ -293,7 +293,7 @@ const QueriesTable = ({ } - path={PATHS.QUERY( + path={PATHS.QUERY_DETAILS( cellProps.row.original.id, cellProps.row.original.team_id ?? currentTeamId )} diff --git a/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx index a3d037c6e0..19b5d0ebeb 100644 --- a/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx +++ b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx @@ -17,6 +17,11 @@ import { IQueryReport } from "interfaces/query_report"; import queryAPI from "services/entities/queries"; import queryReportAPI, { ISortOption } from "services/entities/query_report"; +import { DOCUMENT_TITLE_SUFFIX } from "utilities/constants"; +import { + isGlobalObserver, + isTeamObserver, +} from "utilities/permissions/permissions"; import Spinner from "components/Spinner/Spinner"; import Button from "components/buttons/Button"; @@ -55,6 +60,9 @@ const QueryDetailsPage = ({ location, }: IQueryDetailsPageProps): JSX.Element => { const queryId = parseInt(paramsQueryId, 10); + if (isNaN(queryId)) { + router.push(PATHS.MANAGE_QUERIES); + } const queryParams = location.query; const teamId = location.query.team_id ? parseInt(location.query.team_id, 10) @@ -72,6 +80,7 @@ const QueryDetailsPage = ({ const handlePageError = useErrorHandler(); const { + currentUser, isGlobalAdmin, isGlobalMaintainer, isTeamMaintainerOrTeamAdmin, @@ -81,7 +90,6 @@ const QueryDetailsPage = ({ filteredQueriesPath, availableTeams, setCurrentTeam, - currentTeam, } = useContext(AppContext); const { lastEditedQueryName, @@ -102,7 +110,7 @@ const QueryDetailsPage = ({ } = useContext(QueryContext); // Title that shows up on browser tabs (e.g., Query details | Discover TLS certificates | Fleet for osquery) - document.title = `Query details | ${lastEditedQueryName} | Fleet for osquery`; + document.title = `Query details | ${lastEditedQueryName} | ${DOCUMENT_TITLE_SUFFIX}`; const [disabledCachingGlobally, setDisabledCachingGlobally] = useState(true); @@ -298,8 +306,15 @@ const QueryDetailsPage = ({ >
Report clipped. A sample of this query's results is included - below. You can still use query automations to complete this report in - your log destination. + below. + { + // Exclude below message for global and team observers/observer+s + !( + (currentUser && isGlobalObserver(currentUser)) || + isTeamObserver(currentUser, teamId ?? null) + ) && + " You can still use query automations to complete this report in your log destination." + }
); diff --git a/frontend/pages/queries/details/components/QueryReport/QueryReport.tsx b/frontend/pages/queries/details/components/QueryReport/QueryReport.tsx index 527528327a..a1fb6dd63d 100644 --- a/frontend/pages/queries/details/components/QueryReport/QueryReport.tsx +++ b/frontend/pages/queries/details/components/QueryReport/QueryReport.tsx @@ -141,7 +141,7 @@ const QueryReport = ({ return (
{ diff --git a/frontend/pages/queries/details/components/QueryReport/QueryReportTableConfig.tsx b/frontend/pages/queries/details/components/QueryReport/QueryReportTableConfig.tsx index ecaef7505f..4c9af9723b 100644 --- a/frontend/pages/queries/details/components/QueryReport/QueryReportTableConfig.tsx +++ b/frontend/pages/queries/details/components/QueryReport/QueryReportTableConfig.tsx @@ -15,7 +15,11 @@ import { import DefaultColumnFilter from "components/TableContainer/DataTable/DefaultColumnFilter"; import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCell"; -import { humanHostLastSeen, internallyTruncateText } from "utilities/helpers"; +import { + getUniqueColumnNamesFromRows, + humanHostLastSeen, + internallyTruncateText, +} from "utilities/helpers"; type IHeaderProps = HeaderProps & { column: ColumnInstance & IDataColumn; @@ -52,12 +56,7 @@ const generateReportColumnConfigsFromResults = (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 uniqueColumnNames = getUniqueColumnNamesFromRows(results); const columnConfigs = uniqueColumnNames.map((key) => { return { diff --git a/frontend/pages/queries/edit/EditQueryPage.tsx b/frontend/pages/queries/edit/EditQueryPage.tsx index a9432c3180..2ad7de13ce 100644 --- a/frontend/pages/queries/edit/EditQueryPage.tsx +++ b/frontend/pages/queries/edit/EditQueryPage.tsx @@ -5,7 +5,7 @@ import { InjectedRouter, Params } from "react-router/lib/Router"; import { AppContext } from "context/app"; import { QueryContext } from "context/query"; -import { DEFAULT_QUERY } from "utilities/constants"; +import { DEFAULT_QUERY, DOCUMENT_TITLE_SUFFIX } from "utilities/constants"; import configAPI from "services/entities/config"; import queryAPI from "services/entities/queries"; import statusAPI from "services/entities/status"; @@ -174,7 +174,7 @@ const EditQueryPage = ({ queryId > 0 && !canEditExistingQuery ) { - router.push(PATHS.QUERY(queryId)); + router.push(PATHS.QUERY_DETAILS(queryId)); } }, [queryId, isTeamMaintainerOrTeamAdmin, isStoredQueryLoading]); @@ -203,7 +203,7 @@ const EditQueryPage = ({ // Updates title that shows up on browser tabs useEffect(() => { // e.g., Query details | Discover TLS certificates | Fleet for osquery - document.title = `Edit query | ${storedQuery?.name} | Fleet for osquery`; + document.title = `Edit query | ${storedQuery?.name} | ${DOCUMENT_TITLE_SUFFIX}`; }, [location.pathname, storedQuery?.name]); useEffect(() => { @@ -215,7 +215,7 @@ const EditQueryPage = ({ setIsQuerySaving(true); try { const { query } = await queryAPI.create(formData); - router.push(PATHS.QUERY(query.id, query.team_id)); + router.push(PATHS.QUERY_DETAILS(query.id, query.team_id)); renderFlash("success", "Query created!"); setBackendValidators({}); } catch (createError: any) { @@ -317,7 +317,7 @@ const EditQueryPage = ({ // Function instead of constant eliminates race condition const backToQueriesPath = () => { - return queryId ? PATHS.QUERY(queryId) : PATHS.MANAGE_QUERIES; + return queryId ? PATHS.QUERY_DETAILS(queryId) : PATHS.MANAGE_QUERIES; }; const showSidebar = diff --git a/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tsx b/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tsx index e7d52c7f07..b3f468492e 100644 --- a/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tsx +++ b/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tsx @@ -331,7 +331,10 @@ const EditQueryForm = ({ .then((response: { query: ISchedulableQuery }) => { setIsSaveAsNewLoading(false); router.push( - PATHS.QUERY(response.query.id, response.query.team_id ?? undefined) + PATHS.QUERY_DETAILS( + response.query.id, + response.query.team_id ?? undefined + ) ); renderFlash("success", `Successfully added query.`); }) diff --git a/frontend/pages/queries/edit/components/QueryResults/QueryResults.tsx b/frontend/pages/queries/edit/components/QueryResults/QueryResults.tsx index 2865aea6c2..1c06cdab66 100644 --- a/frontend/pages/queries/edit/components/QueryResults/QueryResults.tsx +++ b/frontend/pages/queries/edit/components/QueryResults/QueryResults.tsx @@ -194,7 +194,7 @@ const QueryResults = ({ return (
& { column: ColumnInstance & IDataColumn; @@ -52,17 +55,7 @@ const generateColumnConfigsFromRows = ( // typed as any[] to accomodate loose typing of websocket API results: any[] // {col:val, ...} for each row of query results ): Column[] => { - const uniqueColumnNames = Array.from( - results.reduce( - (accOuter, row) => - Object.keys(row).reduce( - (accInner, colNameInRow) => accInner.add(colNameInRow), - accOuter - ), - new Set() // Set prevents listing duplicate headers - ) - ); - + const uniqueColumnNames = getUniqueColumnNamesFromRows(results); const columnsConfigs = uniqueColumnNames.map((colName) => { return { id: colName as string, diff --git a/frontend/pages/queries/live/LiveQueryPage/LiveQueryPage.tsx b/frontend/pages/queries/live/LiveQueryPage/LiveQueryPage.tsx index 958f7f7496..99dfd1cf7d 100644 --- a/frontend/pages/queries/live/LiveQueryPage/LiveQueryPage.tsx +++ b/frontend/pages/queries/live/LiveQueryPage/LiveQueryPage.tsx @@ -6,7 +6,7 @@ import PATHS from "router/paths"; import { AppContext } from "context/app"; import { QueryContext } from "context/query"; -import { LIVE_QUERY_STEPS, DEFAULT_QUERY } from "utilities/constants"; +import { LIVE_QUERY_STEPS, DOCUMENT_TITLE_SUFFIX } from "utilities/constants"; import queryAPI from "services/entities/queries"; import hostAPI from "services/entities/hosts"; import statusAPI from "services/entities/status"; @@ -96,7 +96,7 @@ const RunQueryPage = ({ // Reroute users out of live flow when live queries are globally disabled if (disabledLiveQuery) { queryId - ? router.push(PATHS.QUERY(queryId)) + ? router.push(PATHS.QUERY_DETAILS(queryId)) : router.push(PATHS.NEW_QUERY()); } @@ -168,7 +168,7 @@ const RunQueryPage = ({ // Updates title that shows up on browser tabs useEffect(() => { // e.g., Run live query | Discover TLS certificates | Fleet for osquery - document.title = `Run live query | ${storedQuery?.name} | Fleet for osquery`; + document.title = `Run live query | ${storedQuery?.name} | ${DOCUMENT_TITLE_SUFFIX}`; }, [location.pathname, storedQuery?.name]); const goToQueryEditor = useCallback( diff --git a/frontend/router/index.tsx b/frontend/router/index.tsx index 5d947a7fb4..0db0bc4537 100644 --- a/frontend/router/index.tsx +++ b/frontend/router/index.tsx @@ -57,6 +57,7 @@ import WindowsMdmPage from "pages/admin/IntegrationsPage/cards/MdmSettings/Windo import MacOSMdmPage from "pages/admin/IntegrationsPage/cards/MdmSettings/MacOSMdmPage"; import Scripts from "pages/ManageControlsPage/Scripts/Scripts"; import WindowsAutomaticEnrollmentPage from "pages/admin/IntegrationsPage/cards/AutomaticEnrollment/WindowsAutomaticEnrollmentPage"; +import HostQueryReport from "pages/hosts/details/HostQueryReport"; import SoftwarePage from "pages/SoftwarePage"; import SoftwareTitles from "pages/SoftwarePage/SoftwareTitles"; import SoftwareVersions from "pages/SoftwarePage/SoftwareVersions"; @@ -179,16 +180,21 @@ const routes = ( path="manage/:active_label/labels/:label_id" component={ManageHostsPage} /> - - - - - - - - - + + + + + + + {/* legacy route */} + + + diff --git a/frontend/router/page_titles.ts b/frontend/router/page_titles.ts index 49b7cd6f16..e6233a76c8 100644 --- a/frontend/router/page_titles.ts +++ b/frontend/router/page_titles.ts @@ -1,51 +1,60 @@ // Note: Dynamic page titles are constructed for host, software, query, and policy details on their respective *DetailsPage.tsx file + +import { DOCUMENT_TITLE_SUFFIX } from "utilities/constants"; + // Note: Order matters for use of array.find() (specific subpaths must be listed before their parent path) export default [ - { path: "/dashboard", title: "Dashboard | Fleet for osquery" }, - { path: "/hosts/manage", title: "Manage hosts | Fleet for osquery" }, + { path: "/dashboard", title: `Dashboard | ${DOCUMENT_TITLE_SUFFIX}` }, + { path: "/hosts/manage", title: `Manage hosts | ${DOCUMENT_TITLE_SUFFIX}` }, { path: "/controls/os-updates", - title: "Manage OS updates | Fleet for osquery", + title: `Manage OS updates | ${DOCUMENT_TITLE_SUFFIX}`, }, { path: "/controls/os-settings", - title: "Manage OS settings | Fleet for osquery", + title: `Manage OS settings | ${DOCUMENT_TITLE_SUFFIX}`, }, { path: "/controls/setup-experience", - title: "Manage setup experience | Fleet for osquery", + title: `Manage setup experience | ${DOCUMENT_TITLE_SUFFIX}`, }, - { path: "/software/manage", title: "Manage software | Fleet for osquery" }, - { path: "/queries/manage", title: "Manage queries | Fleet for osquery" }, - { path: "/queries/new", title: "New query | Fleet for osquery" }, - { path: "/policies/manage", title: "Manage policies | Fleet for osquery" }, - { path: "/policies/new", title: "New policy | Fleet for osquery" }, + { + path: `/software/manage", title: "Manage software | ${DOCUMENT_TITLE_SUFFIX}`, + }, + { + path: `/queries/manage", title: "Manage queries | ${DOCUMENT_TITLE_SUFFIX}`, + }, + { path: `/queries/new", title: "New query | ${DOCUMENT_TITLE_SUFFIX}` }, + { + path: `/policies/manage", title: "Manage policies | ${DOCUMENT_TITLE_SUFFIX}`, + }, + { path: `/policies/new", title: "New policy | ${DOCUMENT_TITLE_SUFFIX}` }, { path: "/settings/organization", - title: "Manage organization settings | Fleet for osquery", + title: `Manage organization settings | ${DOCUMENT_TITLE_SUFFIX}`, }, { path: "/settings/integrations", - title: "Manage integration settings | Fleet for osquery", + title: `Manage integration settings | ${DOCUMENT_TITLE_SUFFIX}`, }, { path: "/settings/users", - title: "Manage user settings | Fleet for osquery", + title: `Manage user settings | ${DOCUMENT_TITLE_SUFFIX}`, }, { path: "/settings/teams/members", - title: "Manage team members | Fleet for osquery", + title: `Manage team members | ${DOCUMENT_TITLE_SUFFIX}`, }, { path: "/settings/teams/options", - title: "Manage team options | Fleet for osquery", + title: `Manage team options | ${DOCUMENT_TITLE_SUFFIX}`, }, { path: "/settings/teams", - title: "Manage team settings | Fleet for osquery", + title: `Manage team settings | ${DOCUMENT_TITLE_SUFFIX}`, }, { path: "/profile", - title: "Manage my account | Fleet for osquery", + title: `Manage my account | ${DOCUMENT_TITLE_SUFFIX}`, }, ]; diff --git a/frontend/router/paths.ts b/frontend/router/paths.ts index 217a951694..0f429762b5 100644 --- a/frontend/router/paths.ts +++ b/frontend/router/paths.ts @@ -72,7 +72,7 @@ export default { teamId ? `?team_id=${teamId}` : "" }`; }, - QUERY: (queryId: number, teamId?: number): string => { + QUERY_DETAILS: (queryId: number, teamId?: number): string => { return `${URL_PREFIX}/queries/${queryId}${ teamId ? `?team_id=${teamId}` : "" }`; @@ -102,12 +102,14 @@ export default { HOST_SOFTWARE: (id: number): string => { return `${URL_PREFIX}/hosts/${id}/software`; }, - HOST_SCHEDULE: (id: number): string => { - return `${URL_PREFIX}/hosts/${id}/schedule`; + HOST_QUERIES: (id: number): string => { + return `${URL_PREFIX}/hosts/${id}/queries`; }, HOST_POLICIES: (id: number): string => { return `${URL_PREFIX}/hosts/${id}/policies`; }, + HOST_QUERY_REPORT: (hostId: number, queryId: number): string => + `${URL_PREFIX}/hosts/${hostId}/queries/${queryId}`, DEVICE_USER_DETAILS: (deviceAuthToken: any): string => { return `${URL_PREFIX}/device/${deviceAuthToken}`; }, diff --git a/frontend/services/entities/host_query_report.ts b/frontend/services/entities/host_query_report.ts new file mode 100644 index 0000000000..3b6cde4947 --- /dev/null +++ b/frontend/services/entities/host_query_report.ts @@ -0,0 +1,20 @@ +import sendRequest from "services"; +import endpoints from "utilities/endpoints"; + +export interface IHQRResult { + columns: Record; +} +export interface IGetHQRResponse { + query_id: number; + host_id: number; + host_name: string; + last_fetched: string | null; // timestamp + report_clipped: boolean; + results: IHQRResult[]; +} + +export default { + load: (hostId: number, queryId: number): Promise => { + return sendRequest("GET", endpoints.HOST_QUERY_REPORT(hostId, queryId)); + }, +}; diff --git a/frontend/services/entities/query_report.ts b/frontend/services/entities/query_report.ts index 02855a2644..405dfa4ad4 100644 --- a/frontend/services/entities/query_report.ts +++ b/frontend/services/entities/query_report.ts @@ -30,8 +30,6 @@ 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, @@ -39,8 +37,7 @@ export default { const queryString = buildQueryStringFromParams(queryParams); - const endpoint = `${QUERIES}/${id}/report`; - const path = `${endpoint}?${queryString}`; + const path = `${endpoints.QUERY_REPORT(id)}?${queryString}`; return sendRequest("GET", path); }, }; diff --git a/frontend/styles/global/_global.scss b/frontend/styles/global/_global.scss index 17714c51a3..414b11949b 100644 --- a/frontend/styles/global/_global.scss +++ b/frontend/styles/global/_global.scss @@ -53,17 +53,7 @@ h1 { } a { - color: $core-vibrant-blue; - font-weight: $bold; - font-size: $x-small; - text-decoration: none; - - &:focus-visible { - outline-color: #d9d9fe; - outline-offset: 3px; - outline-style: solid; - outline-width: 2px; - } + @include link; } .__react_component_tooltip { diff --git a/frontend/styles/var/mixins.scss b/frontend/styles/var/mixins.scss index f8fd2d6fe9..6096911bef 100644 --- a/frontend/styles/var/mixins.scss +++ b/frontend/styles/var/mixins.scss @@ -117,7 +117,72 @@ $max-width: 2560px; cursor: default; } -@mixin help-text { - font-size: $xx-small; +@mixin grey-text { color: $ui-fleet-black-75; } + +@mixin help-text { + font-size: $xx-small; + @include grey-text; +} + +@mixin link { + color: $core-vibrant-blue; + font-weight: $bold; + font-size: $x-small; + text-decoration: none; + &:focus-visible { + outline-color: #d9d9fe; + outline-offset: 3px; + outline-style: solid; + outline-width: 2px; + } +} + +@mixin table-link { + display: inline-flex; + align-items: center; + padding: $pad-small $pad-xxsmall; // larger clickable area + gap: $pad-small; + white-space: nowrap; + @include link; +} + +@mixin cell-with-link { + display: inline-flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + +@mixin direction-link { + display: inline-flex; + align-items: center; + padding: $pad-small $pad-xxsmall; // larger clickable area + border-radius: 3px; // Visible while tabbing; + gap: $pad-xsmall; + + &:hover { + color: $core-vibrant-blue-over; + text-decoration: underline; + + svg { + path { + stroke: $core-vibrant-blue-over; + } + } + } +} + +@mixin color-contrasted-sections { + background-color: $ui-off-white; + .section { + display: flex; + flex-direction: column; + background-color: $core-white; + border-radius: 16px; + border: 1px solid $ui-fleet-black-10; + padding: $pad-xxlarge; + box-shadow: 0px 3px 0px rgba(226, 228, 234, 0.4); + } +} diff --git a/frontend/utilities/constants.tsx b/frontend/utilities/constants.tsx index c4e765220f..33720bfbd4 100644 --- a/frontend/utilities/constants.tsx +++ b/frontend/utilities/constants.tsx @@ -302,3 +302,5 @@ export const EMPTY_AGENT_OPTIONS = { }; export const DEFAULT_EMPTY_CELL_VALUE = "---"; + +export const DOCUMENT_TITLE_SUFFIX = "Fleet for osquery"; diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index 545ed0d666..4e60187580 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -23,6 +23,8 @@ export default { GLOBAL_POLICIES: `/${API_VERSION}/fleet/policies`, GLOBAL_SCHEDULE: `/${API_VERSION}/fleet/schedule`, HOST_SUMMARY: `/${API_VERSION}/fleet/host_summary`, + HOST_QUERY_REPORT: (hostId: number, queryId: number) => + `/${API_VERSION}/fleet/hosts/${hostId}/queries/${queryId}`, HOSTS: `/${API_VERSION}/fleet/hosts`, HOSTS_COUNT: `/${API_VERSION}/fleet/hosts/count`, HOSTS_DELETE: `/${API_VERSION}/fleet/hosts/delete`, @@ -84,6 +86,7 @@ export default { PACKS: `/${API_VERSION}/fleet/packs`, PERFORM_REQUIRED_PASSWORD_RESET: `/${API_VERSION}/fleet/perform_required_password_reset`, QUERIES: `/${API_VERSION}/fleet/queries`, + QUERY_REPORT: (id: number) => `/${API_VERSION}/fleet/queries/${id}/report`, RESET_PASSWORD: `/${API_VERSION}/fleet/reset_password`, LIVE_QUERY: `/${API_VERSION}/fleet/queries/run`, SCHEDULE_QUERY: `/${API_VERSION}/fleet/packs/schedule`, diff --git a/frontend/utilities/helpers.tsx b/frontend/utilities/helpers.tsx index c255d4eed1..46c54b1942 100644 --- a/frontend/utilities/helpers.tsx +++ b/frontend/utilities/helpers.tsx @@ -11,8 +11,6 @@ import { trimEnd, union, } from "lodash"; -import { buildQueryStringFromParams } from "utilities/url"; - import md5 from "js-md5"; import { formatDistanceToNow, @@ -23,6 +21,7 @@ import { } from "date-fns"; import yaml from "js-yaml"; +import { buildQueryStringFromParams } from "utilities/url"; import { IHost } from "interfaces/host"; import { ILabel } from "interfaces/label"; import { IPack } from "interfaces/pack"; @@ -828,6 +827,22 @@ export const internallyTruncateText = ( ); +export const getUniqueColumnNamesFromRows = (rows: any[]) => + // rows of type {col:val, col:val, ...}[] + // cannot type more narrowly due to loose typing of websocket API and use of this function + // by QueryResultsTableConfig, where results come from that API + // TODO – narrow this entire chain down to the websocket API level + Array.from( + rows.reduce( + (accOuter, row) => + Object.keys(row).reduce( + (accInner, colNameInRow) => accInner.add(colNameInRow), + accOuter + ), + new Set() + ) + ); + export default { addGravatarUrlToResource, formatConfigDataForServer, @@ -843,6 +858,7 @@ export default { formatPackTargetsForApi, generateRole, generateTeam, + getUniqueColumnNamesFromRows, greyCell, humanHostLastSeen, humanHostEnrolled, diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index a724d42dfd..2b92311ab5 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -372,50 +372,54 @@ func loadHostScheduledQueryStatsDB(ctx context.Context, db sqlx.QueryerContext, if teamID != nil { teamID_ = *teamID } - ds := dialect.From(goqu.I("queries").As("q")).Select( - goqu.I("q.id"), - goqu.I("q.name"), - goqu.I("q.description"), - goqu.I("q.team_id"), - goqu.I("q.schedule_interval").As("schedule_interval"), - goqu.COALESCE(goqu.I("sqs.average_memory"), 0).As("average_memory"), - goqu.COALESCE(goqu.I("sqs.denylisted"), false).As("denylisted"), - goqu.COALESCE(goqu.I("sqs.executions"), 0).As("executions"), - goqu.COALESCE(goqu.I("sqs.last_executed"), goqu.L("timestamp(?)", pastDate)).As("last_executed"), - goqu.COALESCE(goqu.I("sqs.output_size"), 0).As("output_size"), - goqu.COALESCE(goqu.I("sqs.system_time"), 0).As("system_time"), - goqu.COALESCE(goqu.I("sqs.user_time"), 0).As("user_time"), - goqu.COALESCE(goqu.I("sqs.wall_time"), 0).As("wall_time"), - ).LeftJoin( - dialect.From("scheduled_query_stats").As("sqs").Where( - goqu.I("host_id").Eq(hid), - ), - goqu.On(goqu.I("sqs.scheduled_query_id").Eq(goqu.I("q.id"))), - ).Where( - goqu.And( - goqu.Or( - // sq.platform empty or NULL means the scheduled query is set to - // run on all hosts. - goqu.I("q.platform").Eq(""), - goqu.I("q.platform").IsNull(), - // scheduled_queries.platform can be a comma-separated list of - // platforms, e.g. "darwin,windows". - goqu.L("FIND_IN_SET(?, q.platform)", fleet.PlatformFromHost(hostPlatform)).Neq(0), - ), - goqu.I("q.schedule_interval").Gt(0), - goqu.I("q.automations_enabled").IsTrue(), - goqu.Or( - goqu.I("q.team_id").IsNull(), - goqu.I("q.team_id").Eq(teamID_), - ), - ), - ) - sql, args, err := ds.ToSQL() - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "sql build") + + sqlQuery := ` + SELECT + q.id, + q.name, + q.description, + q.team_id, + q.schedule_interval AS schedule_interval, + q.discard_data, + q.automations_enabled, + MAX(qr.last_fetched) as last_fetched, + COALESCE(MAX(sqs.average_memory), 0) AS average_memory, + COALESCE(MAX(sqs.denylisted), false) AS denylisted, + COALESCE(MAX(sqs.executions), 0) AS executions, + COALESCE(MAX(sqs.last_executed), TIMESTAMP(?)) AS last_executed, + COALESCE(MAX(sqs.output_size), 0) AS output_size, + COALESCE(MAX(sqs.system_time), 0) AS system_time, + COALESCE(MAX(sqs.user_time), 0) AS user_time, + COALESCE(MAX(sqs.wall_time), 0) AS wall_time + FROM + queries q + LEFT JOIN scheduled_query_stats sqs ON (q.id = sqs.scheduled_query_id AND sqs.host_id = ?) + LEFT JOIN query_results qr ON (q.id = qr.query_id AND qr.host_id = ?) + WHERE + (q.platform = '' OR q.platform IS NULL OR FIND_IN_SET(?, q.platform) != 0) + AND q.schedule_interval > 0 + AND (q.automations_enabled IS TRUE OR (q.discard_data IS FALSE AND q.logging_type = ?)) + AND (q.team_id IS NULL OR q.team_id = ?) + OR EXISTS ( + SELECT 1 FROM query_results + WHERE query_results.query_id = q.id + AND query_results.host_id = ? + ) + GROUP BY q.id + ` + + args := []interface{}{ + pastDate, + hid, + hid, + fleet.PlatformFromHost(hostPlatform), + fleet.LoggingSnapshot, + teamID_, + hid, } + var stats []fleet.QueryStats - if err := sqlx.SelectContext(ctx, db, &stats, sql, args...); err != nil { + if err := sqlx.SelectContext(ctx, db, &stats, sqlQuery, args...); err != nil { return nil, ctxerr.Wrap(ctx, err, "load query stats") } return stats, nil @@ -690,6 +694,9 @@ func queryStatsToScheduledQueryStats(queriesStats []fleet.QueryStats, packName s Denylisted: queryStats.Denylisted, Executions: queryStats.Executions, Interval: queryStats.Interval, + DiscardData: queryStats.DiscardData, + AutomationsEnabled: queryStats.AutomationsEnabled, + LastFetched: queryStats.LastFetched, LastExecuted: queryStats.LastExecuted, OutputSize: queryStats.OutputSize, SystemTime: queryStats.SystemTime, diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index 260b8efa36..f41b62f3be 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -116,6 +116,7 @@ func TestHosts(t *testing.T) { {"HostsListByDiskEncryptionStatus", testHostsListMacOSSettingsDiskEncryptionStatus}, {"HostsListFailingPolicies", printReadsInTest(testHostsListFailingPolicies)}, {"HostsExpiration", testHostsExpiration}, + {"HostsIncludesScheduledQueriesInPackStats", testHostsIncludesScheduledQueriesInPackStats}, {"HostsAllPackStats", testHostsAllPackStats}, {"HostsPackStatsMultipleHosts", testHostsPackStatsMultipleHosts}, {"HostsPackStatsForPlatform", testHostsPackStatsForPlatform}, @@ -618,17 +619,20 @@ func testHostsWithTeamPackStats(t *testing.T, ds *Datastore) { PackName: pack1.Name, ScheduledQueryName: squery1.Name, - QueryName: query1.Name, - PackID: pack1.ID, - AverageMemory: 8000, - Denylisted: false, - Executions: 164, - Interval: 30, - LastExecuted: time.Unix(1620325191, 0).UTC(), - OutputSize: 1337, - SystemTime: 150, - UserTime: 180, - WallTime: 0, + QueryName: query1.Name, + PackID: pack1.ID, + DiscardData: false, + AutomationsEnabled: false, + LastFetched: nil, + AverageMemory: 8000, + Denylisted: false, + Executions: 164, + Interval: 30, + LastExecuted: time.Unix(1620325191, 0).UTC(), + OutputSize: 1337, + SystemTime: 150, + UserTime: 180, + WallTime: 0, }, } stats2 := []fleet.ScheduledQueryStats{ @@ -636,17 +640,20 @@ func testHostsWithTeamPackStats(t *testing.T, ds *Datastore) { PackName: fmt.Sprintf("team-%d", team.ID), ScheduledQueryName: tpQuery.Name, - QueryName: tpQuery.Name, - PackID: 0, // pack_id will be 0 for stats of queries not in packs. - AverageMemory: 8000, - Denylisted: false, - Executions: 164, - Interval: 30, - LastExecuted: time.Unix(1620325191, 0).UTC(), - OutputSize: 1337, - SystemTime: 150, - UserTime: 180, - WallTime: 0, + QueryName: tpQuery.Name, + PackID: 0, // pack_id will be 0 for stats of queries not in packs. + LastFetched: nil, + DiscardData: tpQuery.DiscardData, + AutomationsEnabled: tpQuery.AutomationsEnabled, + AverageMemory: 8000, + Denylisted: false, + Executions: 164, + Interval: 30, + LastExecuted: time.Unix(1620325191, 0).UTC(), + OutputSize: 1337, + SystemTime: 150, + UserTime: 180, + WallTime: 0, }, } @@ -3785,6 +3792,171 @@ func testHostsExpiration(t *testing.T, ds *Datastore) { require.Len(t, hosts, 5) } +func testHostsIncludesScheduledQueriesInPackStats(t *testing.T, ds *Datastore) { + host, err := ds.NewHost(context.Background(), &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: ptr.String("1"), + UUID: "1", + Hostname: "foo.local", + PrimaryIP: "192.168.1.1", + PrimaryMac: "30-65-EC-6F-C4-58", + Platform: "darwin", + }) + require.NoError(t, err) + require.NotNil(t, host) + + team, err := ds.NewTeam(context.Background(), &fleet.Team{Name: "team1"}) + require.NoError(t, err) + err = ds.AddHostsToTeam(context.Background(), &team.ID, []uint{host.ID}) + require.NoError(t, err) + + query1 := &fleet.Query{ + Name: "Only Logged in Query Report", + Query: "select * from time", + AuthorID: nil, + Platform: "darwin", + Saved: true, + TeamID: nil, + Interval: 60, + Logging: fleet.LoggingSnapshot, + DiscardData: false, + AutomationsEnabled: false, + } + + _, err = ds.NewQuery(context.Background(), query1) + require.NoError(t, err) + + query2 := &fleet.Query{ + Name: "Logged In Report and Log Destination", + Query: "select * from time", + AuthorID: nil, + Platform: "darwin", + Saved: true, + TeamID: nil, + Interval: 60, + Logging: fleet.LoggingSnapshot, + DiscardData: false, + AutomationsEnabled: true, + } + _, err = ds.NewQuery(context.Background(), query2) + require.NoError(t, err) + + // This query should not be included in the pack stats + query3 := &fleet.Query{ + Name: "Not LoggingSnapshot", + Query: "select * from time", + AuthorID: nil, + Platform: "darwin", + Saved: true, + TeamID: nil, + Interval: 60, + Logging: fleet.LoggingDifferential, + DiscardData: false, + AutomationsEnabled: false, // automations not on + } + _, err = ds.NewQuery(context.Background(), query3) + require.NoError(t, err) + + // This query should not be included in the pack stats + query4 := &fleet.Query{ + Name: "Query Report No Interval", + Query: "select * from time", + AuthorID: nil, + Platform: "darwin", + Saved: true, + TeamID: nil, + Interval: 0, + Logging: fleet.LoggingSnapshot, + DiscardData: false, + AutomationsEnabled: false, + } + _, err = ds.NewQuery(context.Background(), query4) + require.NoError(t, err) + + // this query should not be included in the pack stats + query5 := &fleet.Query{ + Name: "Automations No Interval", + Query: "select * from time", + AuthorID: nil, + Platform: "darwin", + Saved: true, + TeamID: nil, + Interval: 0, + Logging: fleet.LoggingSnapshot, + DiscardData: true, + AutomationsEnabled: true, + } + _, err = ds.NewQuery(context.Background(), query5) + require.NoError(t, err) + + query6 := &fleet.Query{ + Name: "Team Query", + Query: "select * from time", + AuthorID: nil, + Platform: "darwin", + Saved: true, + TeamID: &team.ID, + Interval: 60, + Logging: fleet.LoggingSnapshot, + DiscardData: false, + AutomationsEnabled: true, + } + _, err = ds.NewQuery(context.Background(), query6) + require.NoError(t, err) + + hostResult, err := ds.Host(context.Background(), host.ID) + require.NoError(t, err) + + globalQueryStats := hostResult.PackStats[0].QueryStats + require.NotNil(t, hostResult) + require.Equal(t, 2, len(globalQueryStats)) + require.Equal(t, query1.Name, globalQueryStats[0].ScheduledQueryName) + require.Equal(t, query2.Name, globalQueryStats[1].ScheduledQueryName) + + teamQueryStats := hostResult.PackStats[1].QueryStats + require.Equal(t, query6.Name, teamQueryStats[0].ScheduledQueryName) + + // Queries with Query Results should be included in the pack stats + // regardless of the query interval + queryResultRow := []*fleet.ScheduledQueryResultRow{ + { + QueryID: query4.ID, // no interval + HostID: host.ID, + Data: ptr.RawMessage(json.RawMessage(`{"foo": "bar"}`)), + }, + { + QueryID: query4.ID, // no interval + HostID: host.ID, + Data: ptr.RawMessage(json.RawMessage(`{"foo": "baz"}`)), + }, + } + err = ds.OverwriteQueryResultRows(context.Background(), queryResultRow) + require.NoError(t, err) + + hostResult, err = ds.Host(context.Background(), host.ID) + require.NoError(t, err) + require.NotNil(t, hostResult) + + assertContains := func(stats []fleet.ScheduledQueryStats, name string) { + t.Helper() + for _, stat := range stats { + if stat.ScheduledQueryName == name { + return + } + } + t.Errorf("expected to find %s in stats", name) + } + + globalQueryStats = hostResult.PackStats[0].QueryStats + require.Equal(t, 3, len(globalQueryStats)) + assertContains(globalQueryStats, query1.Name) + assertContains(globalQueryStats, query2.Name) + assertContains(globalQueryStats, query4.Name) // no interval, but has a query result +} + func testHostsAllPackStats(t *testing.T, ds *Datastore) { host, err := ds.NewHost(context.Background(), &fleet.Host{ DetailUpdatedAt: time.Now(), diff --git a/server/datastore/mysql/query_results.go b/server/datastore/mysql/query_results.go index 1a9749b1b8..f81b94c5eb 100644 --- a/server/datastore/mysql/query_results.go +++ b/server/datastore/mysql/query_results.go @@ -25,9 +25,7 @@ func (ds *Datastore) OverwriteQueryResultRows(ctx context.Context, rows []*fleet // Count how many rows are already in the database for the given queryID var countExisting int - countStmt := ` - SELECT COUNT(*) FROM query_results WHERE query_id = ? - ` + countStmt := `SELECT COUNT(*) FROM query_results WHERE query_id = ? AND data IS NOT NULL` err = sqlx.GetContext(ctx, tx, &countExisting, countStmt, queryID) if err != nil { return ctxerr.Wrap(ctx, err, "counting existing query results") @@ -90,13 +88,14 @@ func (ds *Datastore) OverwriteQueryResultRows(ctx context.Context, rows []*fleet // TODO(lucas): Any chance we can store hostname in the query_results table? // (to avoid having to left join hosts). +// QueryResultRows returns the query result rows for a given query func (ds *Datastore) QueryResultRows(ctx context.Context, queryID uint, filter fleet.TeamFilter) ([]*fleet.ScheduledQueryResultRow, error) { selectStmt := fmt.Sprintf(` SELECT qr.query_id, qr.host_id, qr.last_fetched, qr.data, h.hostname, h.computer_name, h.hardware_model, h.hardware_serial FROM query_results qr LEFT JOIN hosts h ON (qr.host_id=h.id) - WHERE query_id = ? AND %s + WHERE query_id = ? AND data IS NOT NULL AND %s `, ds.whereFilterHostsByTeams(filter, "h")) results := []*fleet.ScheduledQueryResultRow{} @@ -108,9 +107,11 @@ func (ds *Datastore) QueryResultRows(ctx context.Context, queryID uint, filter f return results, nil } +// ResultCountForQuery counts the query report rows for a given query +// excluding rows with null data func (ds *Datastore) ResultCountForQuery(ctx context.Context, queryID uint) (int, error) { var count int - err := sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(*) FROM query_results WHERE query_id = ?`, queryID) + err := sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(*) FROM query_results WHERE query_id = ? AND data IS NOT NULL`, queryID) if err != nil { return 0, ctxerr.Wrap(ctx, err, "counting query results for query") } @@ -118,12 +119,30 @@ func (ds *Datastore) ResultCountForQuery(ctx context.Context, queryID uint) (int return count, nil } +// ResultCountForQueryAndHost counts the query report rows for a given query and host +// excluding rows with null data func (ds *Datastore) ResultCountForQueryAndHost(ctx context.Context, queryID, hostID uint) (int, error) { var count int - err := sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(*) FROM query_results WHERE query_id = ? AND host_id = ?`, queryID, hostID) + err := sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(*) FROM query_results WHERE query_id = ? AND host_id = ? AND data IS NOT NULL`, queryID, hostID) if err != nil { return 0, ctxerr.Wrap(ctx, err, "counting query results for query and host") } return count, nil } + +// QueryResultRowsForHost returns the query result rows for a given query and host +// including rows with null data +func (ds *Datastore) QueryResultRowsForHost(ctx context.Context, queryID, hostID uint) ([]*fleet.ScheduledQueryResultRow, error) { + selectStmt := ` + SELECT query_id, host_id, last_fetched, data FROM query_results + WHERE query_id = ? AND host_id = ? + ` + results := []*fleet.ScheduledQueryResultRow{} + err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, selectStmt, queryID, hostID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "selecting query result rows for host") + } + + return results, nil +} diff --git a/server/datastore/mysql/query_results_test.go b/server/datastore/mysql/query_results_test.go index 116dd71a5a..ad11177979 100644 --- a/server/datastore/mysql/query_results_test.go +++ b/server/datastore/mysql/query_results_test.go @@ -3,15 +3,12 @@ package mysql import ( "context" "encoding/json" - "fmt" - "strings" "testing" "time" - "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/test" - "github.com/jmoiron/sqlx" "github.com/stretchr/testify/require" ) @@ -22,8 +19,8 @@ func TestQueryResults(t *testing.T) { name string fn func(t *testing.T, ds *Datastore) }{ - {"Save", saveQueryResultRows}, - {"Get", getQueryResultRows}, + {"Get", testGetQueryResultRows}, + {"GetForHost", testGetQueryResultRowsForHost}, {"CountForQuery", testCountResultsForQuery}, {"CountForQueryAndHost", testCountResultsForQueryAndHost}, {"Overwrite", testOverwriteQueryResultRows}, @@ -39,103 +36,64 @@ func TestQueryResults(t *testing.T) { } } -func saveQueryResultRows(t *testing.T, ds *Datastore) { +func testGetQueryResultRows(t *testing.T, ds *Datastore) { user := test.NewUser(t, ds, "Test User", "test@example.com", true) query := test.NewQuery(t, ds, nil, "New Query", "SELECT 1", user.ID, true) host := test.NewHost(t, ds, "hostname123", "192.168.1.100", "1234", "UI8XB1223", time.Now()) mockTime := time.Now().UTC().Truncate(time.Second) - resultRows := []*fleet.ScheduledQueryResultRow{ + // Insert Result Rows for Query1 + query1Rows := []*fleet.ScheduledQueryResultRow{ { QueryID: query.ID, HostID: host.ID, LastFetched: mockTime, - Data: json.RawMessage( - `{"model": "USB Keyboard", "vendor": "Apple Inc."}`, - ), + Data: nil, }, { QueryID: query.ID, HostID: host.ID, LastFetched: mockTime, - Data: json.RawMessage( - `{"model": "USB Mouse", "vendor": "Logitech"}`, - ), + Data: ptr.RawMessage([]byte(`{ + "model": "USB Keyboard", + "vendor": "Apple Inc." + }`)), }, } - - err := ds.SaveQueryResultRows(context.Background(), resultRows) - require.NoError(t, err) -} - -func getQueryResultRows(t *testing.T, ds *Datastore) { - user := test.NewUser(t, ds, "Test User", "test@example.com", true) - query := test.NewQuery(t, ds, nil, "New Query", "SELECT 1", user.ID, true) - host := test.NewHost(t, ds, "hostname123", "192.168.1.100", "1234", "UI8XB1223", time.Now()) - - mockTime := time.Now().UTC().Truncate(time.Second) - - // Insert 2 Result Rows for Query1 - resultRows := []*fleet.ScheduledQueryResultRow{ - { - QueryID: query.ID, - HostID: host.ID, - LastFetched: mockTime, - Data: json.RawMessage( - `{"model": "USB Keyboard", "vendor": "Apple Inc."}`, - ), - }, - { - QueryID: query.ID, - HostID: host.ID, - LastFetched: mockTime, - Data: json.RawMessage( - `{"model": "USB Mouse", "vendor": "Logitech"}`, - ), - }, - } - - err := ds.SaveQueryResultRows(context.Background(), resultRows) + err := ds.OverwriteQueryResultRows(context.Background(), query1Rows) require.NoError(t, err) // Insert Result Row for different Scheduled Query query2 := test.NewQuery(t, ds, nil, "New Query 2", "SELECT 1", user.ID, true) - resultRow3 := []*fleet.ScheduledQueryResultRow{ + query2Rows := []*fleet.ScheduledQueryResultRow{ { QueryID: query2.ID, HostID: host.ID, LastFetched: mockTime, - Data: json.RawMessage( - `{"model": "USB Hub","vendor": "Logitech"}`, - ), + Data: ptr.RawMessage([]byte(`{"model": "USB Hub","vendor": "Logitech"}`)), }, } - err = ds.SaveQueryResultRows(context.Background(), resultRow3) + err = ds.OverwriteQueryResultRows(context.Background(), query2Rows) require.NoError(t, err) - // Assert that Query1 returns 2 results - results, err := ds.QueryResultRowsForHost(context.Background(), resultRows[0].QueryID, resultRows[0].HostID) + results, err := ds.QueryResultRows(context.Background(), query.ID, fleet.TeamFilter{User: test.UserAdmin}) require.NoError(t, err) - require.Len(t, results, 2) - require.Equal(t, resultRows[0].QueryID, results[0].QueryID) - require.Equal(t, resultRows[0].HostID, results[0].HostID) - require.Equal(t, resultRows[0].LastFetched.Unix(), results[0].LastFetched.Unix()) - require.JSONEq(t, string(resultRows[0].Data), string(results[0].Data)) - require.Equal(t, resultRows[1].QueryID, results[1].QueryID) - require.Equal(t, resultRows[1].HostID, results[1].HostID) - require.Equal(t, resultRows[1].LastFetched.Unix(), results[1].LastFetched.Unix()) - require.JSONEq(t, string(resultRows[1].Data), string(results[1].Data)) + require.Len(t, results, 1) // Should not return rows with nil data + require.Equal(t, query1Rows[1].QueryID, results[0].QueryID) + require.Equal(t, query1Rows[1].HostID, results[0].HostID) + require.Equal(t, query1Rows[1].LastFetched.Unix(), results[0].LastFetched.Unix()) + require.JSONEq(t, string(*query1Rows[1].Data), string(*results[0].Data)) // Assert that Query2 returns 1 result - results, err = ds.QueryResultRowsForHost(context.Background(), resultRow3[0].QueryID, resultRow3[0].HostID) + results, err = ds.QueryResultRows(context.Background(), query2.ID, fleet.TeamFilter{User: test.UserAdmin}) require.NoError(t, err) require.Len(t, results, 1) - require.Equal(t, resultRow3[0].QueryID, results[0].QueryID) - require.Equal(t, resultRow3[0].HostID, results[0].HostID) - require.Equal(t, resultRow3[0].LastFetched.Unix(), results[0].LastFetched.Unix()) - require.JSONEq(t, string(resultRow3[0].Data), string(results[0].Data)) + require.Equal(t, query2Rows[0].QueryID, results[0].QueryID) + require.Equal(t, query2Rows[0].HostID, results[0].HostID) + require.Equal(t, query2Rows[0].LastFetched.Unix(), results[0].LastFetched.Unix()) + require.JSONEq(t, string(*query2Rows[0].Data), string(*results[0].Data)) // Assert that QueryResultRowsForHost returns empty slice when no results are found results, err = ds.QueryResultRowsForHost(context.Background(), 999, 999) @@ -143,6 +101,67 @@ func getQueryResultRows(t *testing.T, ds *Datastore) { require.Len(t, results, 0) } +func testGetQueryResultRowsForHost(t *testing.T, ds *Datastore) { + user := test.NewUser(t, ds, "Test User", "test@example.com", true) + query := test.NewQuery(t, ds, nil, "New Query", "SELECT 1", user.ID, true) + host1 := test.NewHost(t, ds, "hostname1", "192.168.1.100", "1111", "UI8XB1223", time.Now()) + host2 := test.NewHost(t, ds, "hostname2", "192.168.1.100", "2222", "UI8XB1223", time.Now()) + + mockTime := time.Now().UTC().Truncate(time.Second) + + // Insert 2 Result Rows for Query1 Host1 + host1ResultRows := []*fleet.ScheduledQueryResultRow{ + { + QueryID: query.ID, + HostID: host1.ID, + LastFetched: mockTime, + Data: nil, + }, + { + QueryID: query.ID, + HostID: host1.ID, + LastFetched: mockTime, + Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), + }, + } + err := ds.OverwriteQueryResultRows(context.Background(), host1ResultRows) + require.NoError(t, err) + + // Insert 1 Result Row for Query1 Host2 + host2ResultRows := []*fleet.ScheduledQueryResultRow{ + { + QueryID: query.ID, + HostID: host2.ID, + LastFetched: mockTime, + Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), + }, + } + err = ds.OverwriteQueryResultRows(context.Background(), host2ResultRows) + require.NoError(t, err) + + // Assert that Query1 returns 2 results for Host1 + results, err := ds.QueryResultRowsForHost(context.Background(), query.ID, host1.ID) + require.NoError(t, err) + require.Len(t, results, 2) // should return rows with nil data + require.Equal(t, host1ResultRows[0].QueryID, results[0].QueryID) + require.Equal(t, host1ResultRows[0].HostID, results[0].HostID) + require.Equal(t, host1ResultRows[0].LastFetched.Unix(), results[0].LastFetched.Unix()) + require.Nil(t, results[0].Data) + require.Equal(t, host1ResultRows[1].QueryID, results[1].QueryID) + require.Equal(t, host1ResultRows[1].HostID, results[1].HostID) + require.Equal(t, host1ResultRows[1].LastFetched.Unix(), results[1].LastFetched.Unix()) + require.JSONEq(t, string(*host1ResultRows[1].Data), string(*results[1].Data)) + + // Assert that Query1 returns 1 result for Host2 + results, err = ds.QueryResultRowsForHost(context.Background(), query.ID, host2.ID) + require.NoError(t, err) + require.Len(t, results, 1) + require.Equal(t, host2ResultRows[0].QueryID, results[0].QueryID) + require.Equal(t, host2ResultRows[0].HostID, results[0].HostID) + require.Equal(t, host2ResultRows[0].LastFetched.Unix(), results[0].LastFetched.Unix()) + require.JSONEq(t, string(*host2ResultRows[0].Data), string(*results[0].Data)) +} + func testQueryResultRowsTeamFilter(t *testing.T, ds *Datastore) { team, err := ds.NewTeam(context.Background(), &fleet.Team{ Name: "teamFoo", @@ -188,10 +207,10 @@ func testQueryResultRowsTeamFilter(t *testing.T, ds *Datastore) { QueryID: query.ID, HostID: globalHost.ID, LastFetched: mockTime, - Data: json.RawMessage(`{ + Data: ptr.RawMessage(json.RawMessage(`{ "model": "Global USB Keyboard", "vendor": "Global Inc." - }`), + }`)), }, } @@ -203,10 +222,10 @@ func testQueryResultRowsTeamFilter(t *testing.T, ds *Datastore) { QueryID: query.ID, HostID: teamHost.ID, LastFetched: mockTime, - Data: json.RawMessage(`{ + Data: ptr.RawMessage(json.RawMessage(`{ "model": "Team USB Keyboard", "vendor": "Team Inc." - }`), + }`)), }, } err = ds.OverwriteQueryResultRows(context.Background(), teamRow) @@ -217,10 +236,10 @@ func testQueryResultRowsTeamFilter(t *testing.T, ds *Datastore) { QueryID: query.ID, HostID: observerTeamHost.ID, LastFetched: mockTime, - Data: json.RawMessage(`{ + Data: ptr.RawMessage(json.RawMessage(`{ "model": "Team USB Keyboard", "vendor": "Team Inc." - }`), + }`)), }, } err = ds.OverwriteQueryResultRows(context.Background(), observerTeamRow) @@ -238,54 +257,68 @@ func testQueryResultRowsTeamFilter(t *testing.T, ds *Datastore) { require.Equal(t, teamRow[0].HostID, results[0].HostID) require.Equal(t, teamRow[0].QueryID, results[0].QueryID) require.Equal(t, teamRow[0].LastFetched, results[0].LastFetched) - require.JSONEq(t, string(teamRow[0].Data), string(results[0].Data)) + require.JSONEq(t, string(*teamRow[0].Data), string(*results[0].Data)) require.Equal(t, observerTeamRow[0].HostID, results[1].HostID) require.Equal(t, observerTeamRow[0].QueryID, results[1].QueryID) require.Equal(t, observerTeamRow[0].LastFetched, results[1].LastFetched) - require.JSONEq(t, string(observerTeamRow[0].Data), string(results[1].Data)) + require.JSONEq(t, string(*observerTeamRow[0].Data), string(*results[1].Data)) } func testCountResultsForQuery(t *testing.T, ds *Datastore) { user := test.NewUser(t, ds, "Test User", "test@example.com", true) query1 := test.NewQuery(t, ds, nil, "New Query", "SELECT 1", user.ID, true) query2 := test.NewQuery(t, ds, nil, "New Query 2", "SELECT 1", user.ID, true) - host := test.NewHost(t, ds, "hostname123", "192.168.1.100", "1234", "UI8XB1223", time.Now()) + host := test.NewHost(t, ds, "hostname1", "192.168.1.101", "1111", "UI8XB1223", time.Now()) + host2 := test.NewHost(t, ds, "hostname1", "192.168.1.102", "2222", "UI8XB1224", time.Now()) mockTime := time.Now().UTC().Truncate(time.Second) // Insert 1 Result Row for Query1 - resultRow := []*fleet.ScheduledQueryResultRow{ + host1ResultRow := []*fleet.ScheduledQueryResultRow{ { QueryID: query1.ID, HostID: host.ID, LastFetched: mockTime, - Data: json.RawMessage(`{ + Data: ptr.RawMessage([]byte(`{ "model": "USB Keyboard", "vendor": "Apple Inc." - }`), + }`)), }, } + err := ds.OverwriteQueryResultRows(context.Background(), host1ResultRow) + require.NoError(t, err) - err := ds.SaveQueryResultRows(context.Background(), resultRow) + // Insert Nil Result Row for Query1, nil data rows are not counted + host2ResultRow := []*fleet.ScheduledQueryResultRow{ + { + QueryID: query1.ID, + HostID: host2.ID, + LastFetched: mockTime, + Data: nil, + }, + } + err = ds.OverwriteQueryResultRows(context.Background(), host2ResultRow) require.NoError(t, err) // Insert 5 Result Rows for Query2 - resultRow2 := []*fleet.ScheduledQueryResultRow{ - { - QueryID: query2.ID, - HostID: host.ID, - LastFetched: mockTime, - Data: json.RawMessage(`{ + resultRow2 := &fleet.ScheduledQueryResultRow{ + QueryID: query2.ID, + HostID: host.ID, + LastFetched: mockTime, + Data: ptr.RawMessage([]byte(`{ "model": "USB Mouse", "vendor": "Apple Inc." - }`), - }, + }`)), } + + var resultRows []*fleet.ScheduledQueryResultRow for i := 0; i < 5; i++ { - err = ds.SaveQueryResultRows(context.Background(), resultRow2) - require.NoError(t, err) + resultRows = append(resultRows, resultRow2) } + err = ds.OverwriteQueryResultRows(context.Background(), resultRows) + require.NoError(t, err) + // Assert that ResultCountForQuery returns 1 count, err := ds.ResultCountForQuery(context.Background(), query1.ID) require.NoError(t, err) @@ -296,7 +329,7 @@ func testCountResultsForQuery(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Equal(t, 5, count) - // Returns empty result when no results are found + // Returns 0 when no results are found count, err = ds.ResultCountForQuery(context.Background(), 999) require.NoError(t, err) require.Equal(t, 0, count) @@ -306,66 +339,98 @@ func testCountResultsForQueryAndHost(t *testing.T, ds *Datastore) { user := test.NewUser(t, ds, "Test User", "test@example.com", true) query1 := test.NewQuery(t, ds, nil, "New Query", "SELECT 1", user.ID, true) query2 := test.NewQuery(t, ds, nil, "New Query 2", "SELECT 1", user.ID, true) - host := test.NewHost(t, ds, "host1", "192.168.1.100", "1234", "UI8XB1223", time.Now()) + host1 := test.NewHost(t, ds, "host1", "192.168.1.100", "1234", "UI8XB1223", time.Now()) host2 := test.NewHost(t, ds, "host2", "192.168.1.101", "4567", "UI8XB1224", time.Now()) + host3 := test.NewHost(t, ds, "host3", "192.168.1.102", "8910", "UI8XB1225", time.Now()) mockTime := time.Now().UTC().Truncate(time.Second) - resultRows := []*fleet.ScheduledQueryResultRow{ + host1ResultRows := []*fleet.ScheduledQueryResultRow{ { QueryID: query1.ID, - HostID: host.ID, + HostID: host1.ID, LastFetched: mockTime, - Data: json.RawMessage(`{ + Data: ptr.RawMessage([]byte(`{ "model": "USB Keyboard", "vendor": "Apple Inc." - }`), + }`)), }, { QueryID: query1.ID, - HostID: host.ID, + HostID: host1.ID, LastFetched: mockTime, - Data: json.RawMessage(`{ + Data: ptr.RawMessage([]byte(`{ "model": "USB Mouse", "vendor": "Logitech" - }`), + }`)), }, + } + err := ds.OverwriteQueryResultRows(context.Background(), host1ResultRows) + require.NoError(t, err) + + host1Query2 := []*fleet.ScheduledQueryResultRow{ + { + QueryID: query2.ID, + HostID: host1.ID, + LastFetched: mockTime, + Data: ptr.RawMessage([]byte(`{ + "model": "USB Mouse", + "vendor": "Logitech" + }`)), + }, + } + err = ds.OverwriteQueryResultRows(context.Background(), host1Query2) + require.NoError(t, err) + + host2ResultRow := []*fleet.ScheduledQueryResultRow{ { QueryID: query1.ID, HostID: host2.ID, LastFetched: mockTime, - Data: json.RawMessage(`{ + Data: ptr.RawMessage([]byte(`{ "model": "USB Mouse", "vendor": "Logitech" - }`), - }, - { - QueryID: query2.ID, - HostID: host.ID, - LastFetched: mockTime, - Data: json.RawMessage(`{ - "foo": "bar" - }`), + }`)), }, } + err = ds.OverwriteQueryResultRows(context.Background(), host2ResultRow) + require.NoError(t, err) - err := ds.SaveQueryResultRows(context.Background(), resultRows) + host3ResultRow := []*fleet.ScheduledQueryResultRow{ + { + QueryID: query2.ID, + HostID: host3.ID, + LastFetched: mockTime, + Data: nil, + }, + } + err = ds.OverwriteQueryResultRows(context.Background(), host3ResultRow) require.NoError(t, err) // Assert that Query1 returns 2 - count, err := ds.ResultCountForQueryAndHost(context.Background(), query1.ID, host.ID) + count, err := ds.ResultCountForQueryAndHost(context.Background(), query1.ID, host1.ID) require.NoError(t, err) require.Equal(t, 2, count) // Assert that ResultCountForQuery returns 1 - count, err = ds.ResultCountForQueryAndHost(context.Background(), query2.ID, host.ID) + count, err = ds.ResultCountForQueryAndHost(context.Background(), query2.ID, host1.ID) require.NoError(t, err) require.Equal(t, 1, count) - // Returns empty result when no results are found - count, err = ds.ResultCountForQueryAndHost(context.Background(), 999, host.ID) + // Assert that host2 returns 1 row + count, err = ds.ResultCountForQueryAndHost(context.Background(), query1.ID, host2.ID) require.NoError(t, err) - require.Equal(t, 0, count) + require.Equal(t, 1, count) + + // Assert Nil Data rows are not counted + count, err = ds.ResultCountForQueryAndHost(context.Background(), query2.ID, host3.ID) + require.NoError(t, err) + require.Zero(t, count) + + // Returns empty result when no results are found + count, err = ds.ResultCountForQueryAndHost(context.Background(), 999, host1.ID) + require.NoError(t, err) + require.Zero(t, count) } func testOverwriteQueryResultRows(t *testing.T, ds *Datastore) { @@ -376,18 +441,16 @@ func testOverwriteQueryResultRows(t *testing.T, ds *Datastore) { mockTime := time.Now().UTC().Truncate(time.Second) // Insert initial Result Rows - initialRows := []*fleet.ScheduledQueryResultRow{ + initialRow := []*fleet.ScheduledQueryResultRow{ { QueryID: query.ID, HostID: host.ID, LastFetched: mockTime, - Data: json.RawMessage( - `{"model": "USB Keyboard", "vendor": "Apple Inc."}`, - ), + Data: ptr.RawMessage([]byte(`{"model": "USB Keyboard", "vendor": "Apple Inc."}`)), }, } - err := ds.SaveQueryResultRows(context.Background(), initialRows) + err := ds.OverwriteQueryResultRows(context.Background(), initialRow) require.NoError(t, err) // Overwrite Result Rows with new data @@ -397,9 +460,7 @@ func testOverwriteQueryResultRows(t *testing.T, ds *Datastore) { QueryID: query.ID, HostID: host.ID, LastFetched: newMockTime, - Data: json.RawMessage( - `{"model": "USB Mouse", "vendor": "Logitech"}`, - ), + Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), }, } @@ -413,7 +474,7 @@ func testOverwriteQueryResultRows(t *testing.T, ds *Datastore) { require.Equal(t, overwriteRows[0].QueryID, results[0].QueryID) require.Equal(t, overwriteRows[0].HostID, results[0].HostID) require.Equal(t, overwriteRows[0].LastFetched.Unix(), results[0].LastFetched.Unix()) - require.JSONEq(t, string(overwriteRows[0].Data), string(results[0].Data)) + require.JSONEq(t, string(*overwriteRows[0].Data), string(*results[0].Data)) // Test calling OverwriteQueryResultRows with a query that doesn't exist (e.g. a deleted query). overwriteRows = []*fleet.ScheduledQueryResultRow{ @@ -421,60 +482,124 @@ func testOverwriteQueryResultRows(t *testing.T, ds *Datastore) { QueryID: 9999, HostID: host.ID, LastFetched: newMockTime, - Data: json.RawMessage( - `{"model": "USB Mouse", "vendor": "Logitech"}`, - ), + Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), }, } err = ds.OverwriteQueryResultRows(context.Background(), overwriteRows) require.NoError(t, err) + + // Assert that the data has not changed + results, err = ds.QueryResultRowsForHost(context.Background(), overwriteRows[0].QueryID, overwriteRows[0].HostID) + require.NoError(t, err) + require.Len(t, results, 1) + require.Equal(t, overwriteRows[0].QueryID, results[0].QueryID) + require.Equal(t, overwriteRows[0].HostID, results[0].HostID) + require.Equal(t, overwriteRows[0].LastFetched.Unix(), results[0].LastFetched.Unix()) + require.JSONEq(t, string(*overwriteRows[0].Data), string(*results[0].Data)) } func testQueryResultRowsDoNotExceedMaxRows(t *testing.T, ds *Datastore) { user := test.NewUser(t, ds, "Test User", "test@example.com", true) query := test.NewQuery(t, ds, nil, "Overwrite Test Query", "SELECT 1", user.ID, true) - host := test.NewHost(t, ds, "hostname1", "192.168.1.101", "12345", "UI8XB1224", time.Now()) + query2 := test.NewQuery(t, ds, nil, "Overwrite Test Query 2", "SELECT 1", user.ID, true) + host1 := test.NewHost(t, ds, "hostname1", "192.168.1.101", "11111", "UI8XB1221", time.Now()) + host2 := test.NewHost(t, ds, "hostname2", "192.168.1.101", "22222", "UI8XB1222", time.Now()) + host3 := test.NewHost(t, ds, "hostname3", "192.168.1.101", "33333", "UI8XB1223", time.Now()) + host4 := test.NewHost(t, ds, "hostname4", "192.168.1.101", "44444", "UI8XB1224", time.Now()) mockTime := time.Now().UTC().Truncate(time.Second) - // Generate more than max rows + // Generate max rows -1 + maxRows := fleet.MaxQueryReportRows - 1 + maxMinusOneRows := make([]*fleet.ScheduledQueryResultRow, maxRows) + for i := 0; i < maxRows; i++ { + maxMinusOneRows[i] = &fleet.ScheduledQueryResultRow{ + QueryID: query.ID, + HostID: host1.ID, + LastFetched: mockTime, + Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), + } + } + err := ds.OverwriteQueryResultRows(context.Background(), maxMinusOneRows) + require.NoError(t, err) + + // Add an empty data rows which do not count towards the max + err = ds.OverwriteQueryResultRows(context.Background(), []*fleet.ScheduledQueryResultRow{ + { + QueryID: query.ID, + HostID: host2.ID, + LastFetched: mockTime, + Data: nil, + }, + }) + require.NoError(t, err) + + // Confirm that we can still add a row + err = ds.OverwriteQueryResultRows(context.Background(), []*fleet.ScheduledQueryResultRow{ + { + QueryID: query.ID, + HostID: host3.ID, + LastFetched: mockTime, + Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), + }, + }) + require.NoError(t, err) + + // Assert that we now have max rows + count, err := ds.ResultCountForQuery(context.Background(), query.ID) + require.NoError(t, err) + require.Equal(t, fleet.MaxQueryReportRows, count) + + // Attempt to add another row + err = ds.OverwriteQueryResultRows(context.Background(), []*fleet.ScheduledQueryResultRow{ + { + QueryID: query.ID, + HostID: host4.ID, + LastFetched: mockTime, + Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), + }, + }) + require.NoError(t, err) + + // Assert that the last row was not added + host4result, err := ds.QueryResultRowsForHost(context.Background(), query.ID, host4.ID) + require.NoError(t, err) + require.Len(t, host4result, 0) + + // Generate more than max rows in Query 2 rows := fleet.MaxQueryReportRows + 50 largeBatchRows := make([]*fleet.ScheduledQueryResultRow, rows) for i := 0; i < rows; i++ { largeBatchRows[i] = &fleet.ScheduledQueryResultRow{ - QueryID: query.ID, - HostID: host.ID, + QueryID: query2.ID, + HostID: host1.ID, LastFetched: mockTime, - Data: json.RawMessage(`{"model": "Bulk Mouse", "vendor": "BulkTech"}`), + Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), } } - - err := ds.OverwriteQueryResultRows(context.Background(), largeBatchRows) + err = ds.OverwriteQueryResultRows(context.Background(), largeBatchRows) require.NoError(t, err) // Confirm only max rows are stored for the queryID - allResults, err := ds.QueryResultRowsForHost(context.Background(), query.ID, host.ID) + allResults, err := ds.QueryResultRowsForHost(context.Background(), query2.ID, host1.ID) require.NoError(t, err) require.Len(t, allResults, fleet.MaxQueryReportRows) // Confirm that new rows are not added when the max is reached - host2 := test.NewHost(t, ds, "hostname2", "192.168.1.102", "678910", "UI8XB1225", time.Now()) newMockTime := mockTime.Add(2 * time.Minute) overwriteRows := []*fleet.ScheduledQueryResultRow{ { - QueryID: query.ID, + QueryID: query2.ID, HostID: host2.ID, LastFetched: newMockTime, - Data: json.RawMessage( - `{"model": "USB Mouse", "vendor": "Logitech"}`, - ), + Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), }, } err = ds.OverwriteQueryResultRows(context.Background(), overwriteRows) require.NoError(t, err) - host2Results, err := ds.QueryResultRowsForHost(context.Background(), query.ID, host2.ID) + host2Results, err := ds.QueryResultRowsForHost(context.Background(), query2.ID, host2.ID) require.NoError(t, err) require.Len(t, host2Results, 0) } @@ -490,9 +615,7 @@ func testQueryResultRows(t *testing.T, ds *Datastore) { QueryID: query.ID, HostID: 9999, LastFetched: mockTime, - Data: json.RawMessage( - `{"model": "USB Mouse", "vendor": "Logitech"}`, - ), + Data: ptr.RawMessage([]byte(`{"model": "USB Mouse", "vendor": "Logitech"}`)), }, } err := ds.OverwriteQueryResultRows(context.Background(), overwriteRows) @@ -505,43 +628,3 @@ func testQueryResultRows(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Len(t, results, 1) } - -func (ds *Datastore) SaveQueryResultRows(ctx context.Context, rows []*fleet.ScheduledQueryResultRow) error { - if len(rows) == 0 { - return nil // Nothing to insert - } - - valueStrings := make([]string, 0, len(rows)) - valueArgs := make([]interface{}, 0, len(rows)*4) - - for _, row := range rows { - valueStrings = append(valueStrings, "(?, ?, ?, ?)") - valueArgs = append(valueArgs, row.QueryID, row.HostID, row.LastFetched, row.Data) - } - - insertStmt := fmt.Sprintf(` - INSERT INTO query_results (query_id, host_id, last_fetched, data) - VALUES %s - `, strings.Join(valueStrings, ",")) - - _, err := ds.writer(ctx).ExecContext(ctx, insertStmt, valueArgs...) - if err != nil { - return err - } - - return nil -} - -func (ds *Datastore) QueryResultRowsForHost(ctx context.Context, queryID, hostID uint) ([]*fleet.ScheduledQueryResultRow, error) { - selectStmt := ` - SELECT query_id, host_id, last_fetched, data FROM query_results - WHERE query_id = ? AND host_id = ? - ` - results := []*fleet.ScheduledQueryResultRow{} - err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, selectStmt, queryID, hostID) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "selecting query result rows for host") - } - - return results, nil -} diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index cccaf822e4..1fa8bedd77 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -401,6 +401,7 @@ type Datastore interface { // QueryResultRows returns stored results of a query QueryResultRows(ctx context.Context, queryID uint, filter TeamFilter) ([]*ScheduledQueryResultRow, error) + QueryResultRowsForHost(ctx context.Context, queryID, hostID uint) ([]*ScheduledQueryResultRow, error) ResultCountForQuery(ctx context.Context, queryID uint) (int, error) ResultCountForQueryAndHost(ctx context.Context, queryID, hostID uint) (int, error) OverwriteQueryResultRows(ctx context.Context, rows []*ScheduledQueryResultRow) error diff --git a/server/fleet/queries.go b/server/fleet/queries.go index cfc655bbc4..0e1aa84f80 100644 --- a/server/fleet/queries.go +++ b/server/fleet/queries.go @@ -414,12 +414,15 @@ type QueryStats struct { Denylisted bool `json:"denylisted" db:"denylisted"` Executions uint64 `json:"executions" db:"executions"` // Note schedule_interval is used for DB since "interval" is a reserved word in MySQL - Interval int `json:"interval" db:"schedule_interval"` - LastExecuted time.Time `json:"last_executed" db:"last_executed"` - OutputSize uint64 `json:"output_size" db:"output_size"` - SystemTime uint64 `json:"system_time" db:"system_time"` - UserTime uint64 `json:"user_time" db:"user_time"` - WallTime uint64 `json:"wall_time" db:"wall_time"` + Interval int `json:"interval" db:"schedule_interval"` + DiscardData bool `json:"discard_data" db:"discard_data"` + LastFetched *time.Time `json:"last_fetched" db:"last_fetched"` + AutomationsEnabled bool `json:"automations_enabled" db:"automations_enabled"` + LastExecuted time.Time `json:"last_executed" db:"last_executed"` + OutputSize uint64 `json:"output_size" db:"output_size"` + SystemTime uint64 `json:"system_time" db:"system_time"` + UserTime uint64 `json:"user_time" db:"user_time"` + WallTime uint64 `json:"wall_time" db:"wall_time"` } // MapQueryReportsResultsToRows converts the scheduled query results as stored in Fleet's database @@ -428,7 +431,10 @@ func MapQueryReportResultsToRows(rows []*ScheduledQueryResultRow) ([]HostQueryRe var results []HostQueryResultRow for _, row := range rows { var columns map[string]string - if err := json.Unmarshal(row.Data, &columns); err != nil { + if row.Data == nil { + continue + } + if err := json.Unmarshal(*row.Data, &columns); err != nil { return nil, err } results = append(results, HostQueryResultRow{ @@ -455,6 +461,12 @@ type HostQueryResultRow struct { Columns map[string]string `json:"columns"` } +type HostQueryReportResult struct { + // Columns contains the key-value pairs of a result row. + // The map key is the name of the column, and the map value is the value. + Columns map[string]string `json:"columns"` +} + // ScheduledQueryResult holds results of a scheduled query received from a osquery agent. type ScheduledQueryResult struct { // QueryName is the name of the query. @@ -463,7 +475,7 @@ type ScheduledQueryResult struct { OsqueryHostID string `json:"hostIdentifier"` // Snapshot holds the result rows. It's an array of maps, where the map keys // are column names and map values are the values. - Snapshot []json.RawMessage `json:"snapshot"` + Snapshot []*json.RawMessage `json:"snapshot"` // LastFetched is the time this result was received. UnixTime uint `json:"unixTime"` } @@ -484,7 +496,7 @@ type ScheduledQueryResultRow struct { HardwareSerial sql.NullString `db:"hardware_serial"` // Data holds a single result row. It holds a map where the map keys // are column names and map values are the values. - Data json.RawMessage `db:"data"` + Data *json.RawMessage `db:"data"` // LastFetched is the time this result was received. LastFetched time.Time `db:"last_fetched"` } diff --git a/server/fleet/queries_test.go b/server/fleet/queries_test.go index ec7b2558e0..092e510faa 100644 --- a/server/fleet/queries_test.go +++ b/server/fleet/queries_test.go @@ -2,7 +2,6 @@ package fleet import ( "database/sql" - "encoding/json" "testing" "time" @@ -231,7 +230,7 @@ func TestMapQueryReportResultRows(t *testing.T) { HostID: 1, Hostname: sql.NullString{String: "macOS host", Valid: true}, LastFetched: macOSUSBDevicesLastFetched, - Data: json.RawMessage(`{ + Data: ptr.RawMessage([]byte(`{ "class": "9", "model": "AppleUSBVHCIBCE Root Hub Simulation", "model_id": "8000", @@ -244,13 +243,13 @@ func TestMapQueryReportResultRows(t *testing.T) { "vendor": "Apple Inc.", "vendor_id": "05bc", "version": "0.0" - }`), + }`)), }, { HostID: 1, Hostname: sql.NullString{String: "macOS host", Valid: true}, LastFetched: macOSUSBDevicesLastFetched, - Data: json.RawMessage(`{ + Data: ptr.RawMessage([]byte(`{ "class": "9", "model": "AppleUSBXHCI Root Hub Simulation", "model_id": "8007", @@ -263,13 +262,13 @@ func TestMapQueryReportResultRows(t *testing.T) { "vendor": "Apple Inc.", "vendor_id": "05ac", "version": "0.0" - }`), + }`)), }, { HostID: 2, Hostname: sql.NullString{String: "ubuntu host", Valid: true}, LastFetched: ubuntuUSBDevicesLastFetched, - Data: json.RawMessage(`{ + Data: ptr.RawMessage([]byte(`{ "class": "9", "model": "1.1 root hub", "model_id": "0001", @@ -282,7 +281,7 @@ func TestMapQueryReportResultRows(t *testing.T) { "vendor": "Linux Foundation", "vendor_id": "1d6b", "version": "0602" - }`), + }`)), }, }, expected: []HostQueryResultRow{ @@ -353,7 +352,7 @@ func TestMapQueryReportResultRows(t *testing.T) { HostID: 1, Hostname: sql.NullString{String: "macOS host", Valid: true}, LastFetched: macOSOsqueryInfoLastFetched, - Data: json.RawMessage(`{ + Data: ptr.RawMessage([]byte(`{ "build_distro": "10.14", "build_platform": "darwin", "config_hash": "eed0d8296e5f90b790a23814a9db7a127b13498d", @@ -366,7 +365,7 @@ func TestMapQueryReportResultRows(t *testing.T) { "uuid": "589966AE-074A-503B-B17B-54B05684A120", "version": "5.9.1", "watcher": "96729" - }`), + }`)), }, }, expected: []HostQueryResultRow{ @@ -399,7 +398,7 @@ func TestMapQueryReportResultRows(t *testing.T) { HostID: 3, Hostname: sql.NullString{String: "bar", Valid: true}, LastFetched: time.Now(), - Data: json.RawMessage(`invalid JSON`), + Data: ptr.RawMessage([]byte(`invalid JSON`)), }, }, shouldFail: true, @@ -411,7 +410,7 @@ func TestMapQueryReportResultRows(t *testing.T) { HostID: 3, Hostname: sql.NullString{String: "bar", Valid: true}, LastFetched: time.Now(), - Data: json.RawMessage(`{"foobar": 1}`), + Data: ptr.RawMessage([]byte(`{"foobar": 1}`)), }, }, shouldFail: true, diff --git a/server/fleet/scheduled_queries.go b/server/fleet/scheduled_queries.go index 0eaac54cb5..0c3af78c7f 100644 --- a/server/fleet/scheduled_queries.go +++ b/server/fleet/scheduled_queries.go @@ -155,12 +155,15 @@ type ScheduledQueryStats struct { Denylisted bool `json:"denylisted" db:"denylisted"` Executions uint64 `json:"executions" db:"executions"` // Note schedule_interval is used for DB since "interval" is a reserved word in MySQL - Interval int `json:"interval" db:"schedule_interval"` - LastExecuted time.Time `json:"last_executed" db:"last_executed"` - OutputSize uint64 `json:"output_size" db:"output_size"` - SystemTime uint64 `json:"system_time" db:"system_time"` - UserTime uint64 `json:"user_time" db:"user_time"` - WallTime uint64 `json:"wall_time" db:"wall_time"` + Interval int `json:"interval" db:"schedule_interval"` + DiscardData bool `json:"discard_data" db:"discard_data"` + LastFetched *time.Time `json:"last_fetched" db:"last_fetched"` + AutomationsEnabled bool `json:"automations_enabled" db:"automations_enabled"` + LastExecuted time.Time `json:"last_executed" db:"last_executed"` + OutputSize uint64 `json:"output_size" db:"output_size"` + SystemTime uint64 `json:"system_time" db:"system_time"` + UserTime uint64 `json:"user_time" db:"user_time"` + WallTime uint64 `json:"wall_time" db:"wall_time"` } // TeamID returns the team id if the stat is for a team query stat result diff --git a/server/fleet/service.go b/server/fleet/service.go index 8c07b63155..8c7d94dd1e 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -272,6 +272,10 @@ type Service interface { GetQuery(ctx context.Context, id uint) (*Query, error) // GetQueryReportResults returns all the stored results of a query for hosts the requestor has access to GetQueryReportResults(ctx context.Context, id uint) ([]HostQueryResultRow, error) + // GetHostQueryReportResults returns all stored results of a query for a specific host + GetHostQueryReportResults(ctx context.Context, hid uint, queryID uint) (rows []HostQueryReportResult, lastFetched *time.Time, err error) + // QueryReportIsClipped returns true if the number of query report rows exceeds the maximum + QueryReportIsClipped(ctx context.Context, queryID uint) (bool, error) NewQuery(ctx context.Context, p QueryPayload) (*Query, error) ModifyQuery(ctx context.Context, id uint, p QueryPayload) (*Query, error) DeleteQuery(ctx context.Context, teamID *uint, name string) error @@ -325,6 +329,8 @@ type Service interface { // The return value can also include policy information and CVE scores based // on the values provided to `opts` GetHost(ctx context.Context, id uint, opts HostDetailOptions) (host *HostDetail, err error) + // GetHostLite returns basic host information not requiring table joins + GetHostLite(ctx context.Context, id uint) (host *Host, err error) GetHostHealth(ctx context.Context, id uint) (hostHealth *HostHealth, err error) GetHostSummary(ctx context.Context, teamID *uint, platform *string, lowDiskSpace *int) (summary *HostSummary, err error) DeleteHost(ctx context.Context, id uint) (err error) diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 041184b842..1868edf099 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -300,6 +300,8 @@ type ScheduledQueryIDsByNameFunc func(ctx context.Context, batchSize int, packAn type QueryResultRowsFunc func(ctx context.Context, queryID uint, filter fleet.TeamFilter) ([]*fleet.ScheduledQueryResultRow, error) +type QueryResultRowsForHostFunc func(ctx context.Context, queryID uint, hostID uint) ([]*fleet.ScheduledQueryResultRow, error) + type ResultCountForQueryFunc func(ctx context.Context, queryID uint) (int, error) type ResultCountForQueryAndHostFunc func(ctx context.Context, queryID uint, hostID uint) (int, error) @@ -1196,6 +1198,9 @@ type DataStore struct { QueryResultRowsFunc QueryResultRowsFunc QueryResultRowsFuncInvoked bool + QueryResultRowsForHostFunc QueryResultRowsForHostFunc + QueryResultRowsForHostFuncInvoked bool + ResultCountForQueryFunc ResultCountForQueryFunc ResultCountForQueryFuncInvoked bool @@ -2894,6 +2899,13 @@ func (s *DataStore) QueryResultRows(ctx context.Context, queryID uint, filter fl return s.QueryResultRowsFunc(ctx, queryID, filter) } +func (s *DataStore) QueryResultRowsForHost(ctx context.Context, queryID uint, hostID uint) ([]*fleet.ScheduledQueryResultRow, error) { + s.mu.Lock() + s.QueryResultRowsForHostFuncInvoked = true + s.mu.Unlock() + return s.QueryResultRowsForHostFunc(ctx, queryID, hostID) +} + func (s *DataStore) ResultCountForQuery(ctx context.Context, queryID uint) (int, error) { s.mu.Lock() s.ResultCountForQueryFuncInvoked = true diff --git a/server/service/handler.go b/server/service/handler.go index 3150bf1903..308bf8893f 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -385,6 +385,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/device_mapping", listHostDeviceMappingEndpoint, listHostDeviceMappingRequest{}) ue.GET("/api/_version_/fleet/hosts/report", hostsReportEndpoint, hostsReportRequest{}) ue.GET("/api/_version_/fleet/os_versions", osVersionsEndpoint, osVersionsRequest{}) + ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/queries/{query_id:[0-9]+}", getHostQueryReportEndpoint, getHostQueryReportRequest{}) ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/health", getHostHealthEndpoint, getHostHealthRequest{}) ue.GET("/api/_version_/fleet/hosts/summary/mdm", getHostMDMSummary, getHostMDMSummaryRequest{}) diff --git a/server/service/hosts.go b/server/service/hosts.go index 0b828fb4b3..52e2e03dde 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -509,6 +509,26 @@ func (svc *Service) checkWriteForHostIDs(ctx context.Context, ids []uint) error return nil } +// ////////////////////////////////////////////////////////////////////////////// +// Get Host Lite +// ////////////////////////////////////////////////////////////////////////////// +func (svc *Service) GetHostLite(ctx context.Context, id uint) (*fleet.Host, error) { + if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil { + return nil, err + } + + host, err := svc.ds.HostLite(ctx, id) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get host lite") + } + + if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil { + return nil, err + } + + return host, nil +} + //////////////////////////////////////////////////////////////////////////////// // Get Host Summary //////////////////////////////////////////////////////////////////////////////// @@ -1067,6 +1087,93 @@ func (svc *Service) getHostDetails(ctx context.Context, host *fleet.Host, opts f }, nil } +//////////////////////////////////////////////////////////////////////////////// +// Get Host Query Report +//////////////////////////////////////////////////////////////////////////////// + +type getHostQueryReportRequest struct { + ID uint `url:"id"` + QueryID uint `url:"query_id"` +} + +type getHostQueryReportResponse struct { + QueryID uint `json:"query_id"` + HostID uint `json:"host_id"` + HostName string `json:"host_name"` + LastFetched *time.Time `json:"last_fetched"` + ReportClipped bool `json:"report_clipped"` + Results []fleet.HostQueryReportResult `json:"results"` + Err error `json:"error,omitempty"` +} + +func (r getHostQueryReportResponse) error() error { return r.Err } + +func getHostQueryReportEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*getHostQueryReportRequest) + + // Need to return hostname in response even if there are no report results + host, err := svc.GetHostLite(ctx, req.ID) + if err != nil { + return getHostQueryReportResponse{Err: err}, nil + } + + reportResults, lastFetched, err := svc.GetHostQueryReportResults(ctx, req.ID, req.QueryID) + if err != nil { + return getHostQueryReportResponse{Err: err}, nil + } + + isClipped, err := svc.QueryReportIsClipped(ctx, req.QueryID) + if err != nil { + return getHostQueryReportResponse{Err: err}, nil + } + + return getHostQueryReportResponse{ + QueryID: req.QueryID, + HostID: host.ID, + HostName: host.DisplayName(), + LastFetched: lastFetched, + ReportClipped: isClipped, + Results: reportResults, + }, nil +} + +func (svc *Service) GetHostQueryReportResults(ctx context.Context, hostID uint, queryID uint) ([]fleet.HostQueryReportResult, *time.Time, error) { + query, err := svc.ds.Query(ctx, queryID) + if err != nil { + setAuthCheckedOnPreAuthErr(ctx) + return nil, nil, ctxerr.Wrap(ctx, err, "get query from datastore") + } + if err := svc.authz.Authorize(ctx, query, fleet.ActionRead); err != nil { + return nil, nil, err + } + + rows, err := svc.ds.QueryResultRowsForHost(ctx, queryID, hostID) + if err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "get query result rows for host") + } + + if len(rows) == 0 { + return []fleet.HostQueryReportResult{}, nil, nil + } + + var lastFetched *time.Time + result := make([]fleet.HostQueryReportResult, 0, len(rows)) + for _, row := range rows { + fetched := row.LastFetched // copy to avoid loop reuse issue + lastFetched = &fetched // need to return value even if data is nil + + if row.Data != nil { + columns := map[string]string{} + if err := json.Unmarshal(*row.Data, &columns); err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "unmarshal query result row data") + } + result = append(result, fleet.HostQueryReportResult{Columns: columns}) + } + } + + return result, lastFetched, nil +} + func (svc *Service) hostIDsAndNamesFromFilters(ctx context.Context, opt fleet.HostListOptions, lid *uint) ([]uint, []string, error) { filter, err := processHostFilters(ctx, opt, lid) if err != nil { diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go index add9157dc8..69caf12377 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -659,12 +659,18 @@ func TestHostAuth(t *testing.T) { _, err := svc.GetHost(ctx, 1, opts) checkAuthErr(t, tt.shouldFailTeamRead, err) + _, err = svc.GetHostLite(ctx, 1) + checkAuthErr(t, tt.shouldFailTeamRead, err) + _, err = svc.HostByIdentifier(ctx, "1", opts) checkAuthErr(t, tt.shouldFailTeamRead, err) _, err = svc.GetHost(ctx, 2, opts) checkAuthErr(t, tt.shouldFailGlobalRead, err) + _, err = svc.GetHostLite(ctx, 2) + checkAuthErr(t, tt.shouldFailGlobalRead, err) + _, err = svc.HostByIdentifier(ctx, "2", opts) checkAuthErr(t, tt.shouldFailGlobalRead, err) diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index f76d730a9d..66441c6a4d 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -8567,18 +8567,33 @@ func (s *integrationTestSuite) TestQueryReports() { }) require.NoError(t, err) - host2Team1, err := s.ds.NewHost(ctx, &fleet.Host{ + host2Global, err := s.ds.NewHost(ctx, &fleet.Host{ DetailUpdatedAt: time.Now(), LabelUpdatedAt: time.Now(), PolicyUpdatedAt: time.Now(), SeenTime: time.Now(), NodeKey: ptr.String("2"), UUID: "2", - ComputerName: "Foo Local2", Hostname: "foo.local2", OsqueryHostID: ptr.String("2"), - PrimaryIP: "192.168.1.2", + PrimaryIP: "192.168.1.1", PrimaryMac: "30-65-EC-6F-C4-59", + Platform: "ubuntu", + }) + require.NoError(t, err) + + host2Team1, err := s.ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: ptr.String("3"), + UUID: "3", + ComputerName: "Foo Local3", + Hostname: "foo.local3", + OsqueryHostID: ptr.String("3"), + PrimaryIP: "192.168.1.3", + PrimaryMac: "30-65-EC-6F-C4-60", Platform: "darwin", }) require.NoError(t, err) @@ -8620,6 +8635,16 @@ func (s *integrationTestSuite) TestQueryReports() { require.NotNil(t, gqrr.Results) require.Len(t, gqrr.Results, 0) + var ghqrr getHostQueryReportResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/queries/%d", host1Global.ID, usbDevicesQuery.ID), getHostQueryReportRequest{}, http.StatusOK, &ghqrr) + require.NoError(t, ghqrr.Err) + require.Equal(t, usbDevicesQuery.ID, ghqrr.QueryID) + require.Equal(t, host1Global.ID, ghqrr.HostID) + require.Nil(t, ghqrr.LastFetched) + require.False(t, ghqrr.ReportClipped) + require.NotNil(t, ghqrr.Results) + require.Len(t, ghqrr.Results, 0) + slreq := submitLogsRequest{ NodeKey: *host2Team1.NodeKey, LogType: "result", @@ -8741,6 +8766,29 @@ func (s *integrationTestSuite) TestQueryReports() { s.DoJSON("POST", "/api/osquery/log", slreq, http.StatusOK, &slres) require.NoError(t, slres.Err) + emptyslreq := submitLogsRequest{ + NodeKey: *host2Global.NodeKey, + LogType: "result", + Data: json.RawMessage(`[{ + "snapshot": [], + "action": "snapshot", + "name": "pack/Global/` + osqueryInfoQuery.Name + `", + "hostIdentifier": "` + *host1Global.OsqueryHostID + `", + "calendarTime": "Fri Oct 6 18:13:04 2023 UTC", + "unixTime": 1696615984, + "epoch": 0, + "counter": 0, + "numerics": false, + "decorations": { + "host_uuid": "187c4d56-8e45-1a9d-8513-ac17efd2f0fd", + "hostname": "` + host1Global.Hostname + `" + } + }]`), + } + emptyslres := submitLogsResponse{} + s.DoJSON("POST", "/api/osquery/log", emptyslreq, http.StatusOK, &emptyslres) + require.NoError(t, emptyslres.Err) + gqrr = getQueryReportResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", usbDevicesQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) require.NoError(t, gqrr.Err) @@ -8785,6 +8833,47 @@ func (s *integrationTestSuite) TestQueryReports() { "version": "9.33", }, gqrr.Results[1].Columns) + ghqrr = getHostQueryReportResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/queries/%d", host2Team1.ID, usbDevicesQuery.ID), getHostQueryReportRequest{}, http.StatusOK, &ghqrr) + require.NoError(t, ghqrr.Err) + require.Equal(t, usbDevicesQuery.ID, ghqrr.QueryID) + require.Equal(t, host2Team1.ID, ghqrr.HostID) + require.NotNil(t, ghqrr.LastFetched) + require.False(t, ghqrr.ReportClipped) + require.Len(t, ghqrr.Results, 2) + sort.Slice(gqrr.Results, func(i, j int) bool { + // Let's just pick a known column of the query to sort. + return gqrr.Results[i].Columns["usb_port"] < gqrr.Results[j].Columns["usb_port"] + }) + require.Equal(t, map[string]string{ + "class": "239", + "model": "HD Pro Webcam C920", + "model_id": "0892", + "protocol": "", + "removable": "1", + "serial": "zoobar", + "subclass": "2", + "usb_address": "3", + "usb_port": "1", + "vendor": "", + "vendor_id": "046d", + "version": "0.19", + }, ghqrr.Results[0].Columns) + require.Equal(t, map[string]string{ + "class": "0", + "model": "Apple Internal Keyboard / Trackpad", + "model_id": "027e", + "protocol": "", + "removable": "0", + "serial": "foobar", + "subclass": "0", + "usb_address": "8", + "usb_port": "5", + "vendor": "Apple Inc.", + "vendor_id": "05ac", + "version": "9.33", + }, ghqrr.Results[1].Columns) + gqrr = getQueryReportResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", osqueryInfoQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) require.NoError(t, gqrr.Err) @@ -8829,6 +8918,38 @@ func (s *integrationTestSuite) TestQueryReports() { "watcher": "95636", }, gqrr.Results[1].Columns) + ghqrr = getHostQueryReportResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/queries/%d", host1Global.ID, osqueryInfoQuery.ID), getHostQueryReportRequest{}, http.StatusOK, &ghqrr) + require.NoError(t, ghqrr.Err) + require.Equal(t, osqueryInfoQuery.ID, ghqrr.QueryID) + require.Equal(t, host1Global.ID, ghqrr.HostID) + require.NotNil(t, ghqrr.LastFetched) + require.False(t, ghqrr.ReportClipped) + require.Len(t, ghqrr.Results, 1) + require.Equal(t, map[string]string{ + "build_distro": "centos7", + "build_platform": "linux", + "config_hash": "eed0d8296e5f90b790a23814a9db7a127b13498d", + "config_valid": "1", + "extensions": "active", + "instance_id": "e5799132-85ab-4cfa-89f3-03e0dd3c509a", + "pid": "3574", + "platform_mask": "9", + "start_time": "1696502961", + "uuid": host1Global.UUID, + "version": "5.9.2", + "watcher": "3570", + }, ghqrr.Results[0].Columns) + + ghqrr = getHostQueryReportResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/queries/%d", host2Global.ID, osqueryInfoQuery.ID), getHostQueryReportRequest{}, http.StatusOK, &ghqrr) + require.NoError(t, ghqrr.Err) + require.Equal(t, osqueryInfoQuery.ID, ghqrr.QueryID) + require.Equal(t, host2Global.ID, ghqrr.HostID) + require.NotNil(t, ghqrr.LastFetched) + require.False(t, ghqrr.ReportClipped) + require.Len(t, ghqrr.Results, 0) + // verify that certain modifications to queries don't cause result deletion modifyQueryResp := modifyQueryResponse{} updatedDesc := "Updated description" @@ -8901,7 +9022,13 @@ func (s *integrationTestSuite) TestQueryReports() { s.DoJSON("POST", "/api/osquery/log", slreq, http.StatusOK, &slres) require.NoError(t, slres.Err) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", osqueryInfoQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) - require.Len(t, gqrr.Results, 1000) + require.Len(t, gqrr.Results, fleet.MaxQueryReportRows) + + ghqrr = getHostQueryReportResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/queries/%d", host1Global.ID, osqueryInfoQuery.ID), getHostQueryReportRequest{}, http.StatusOK, &ghqrr) + require.NoError(t, ghqrr.Err) + require.True(t, ghqrr.ReportClipped) + require.Len(t, ghqrr.Results, fleet.MaxQueryReportRows) slreq.Data = json.RawMessage(`[{ "snapshot": [` + results(1, host1Global.UUID) + ` @@ -8923,7 +9050,7 @@ func (s *integrationTestSuite) TestQueryReports() { s.DoJSON("POST", "/api/osquery/log", slreq, http.StatusOK, &slres) require.NoError(t, slres.Err) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/queries/%d/report", osqueryInfoQuery.ID), getQueryReportRequest{}, http.StatusOK, &gqrr) - require.Len(t, gqrr.Results, 1000) + require.Len(t, gqrr.Results, fleet.MaxQueryReportRows) // TODO: Set global discard flag and verify that all data is gone. } diff --git a/server/service/osquery.go b/server/service/osquery.go index 2ee6b0a0b9..8ffc77f2d1 100644 --- a/server/service/osquery.go +++ b/server/service/osquery.go @@ -1578,11 +1578,6 @@ func (svc *Service) saveResultLogsToQueryReports(ctx context.Context, unmarshale filtered := getMostRecentResults(unmarshaledResults) for _, result := range filtered { - // Discard result if there is no snapshot - if len(result.Snapshot) == 0 { - continue - } - dbQuery, ok := queriesDBData[result.QueryName] if !ok { // Means the query does not exist with such name anymore. Thus we ignore its result. @@ -1621,6 +1616,18 @@ func (svc *Service) overwriteResultRows(ctx context.Context, result *fleet.Sched fetchTime := time.Now() rows := make([]*fleet.ScheduledQueryResultRow, 0, len(result.Snapshot)) + + // If the snapshot is empty, we still want to save a row with a null value + // to capture LastFetched. + if len(result.Snapshot) == 0 { + rows = append(rows, &fleet.ScheduledQueryResultRow{ + QueryID: queryID, + HostID: hostID, + Data: nil, + LastFetched: fetchTime, + }) + } + for _, snapshotItem := range result.Snapshot { row := &fleet.ScheduledQueryResultRow{ QueryID: queryID, diff --git a/server/service/osquery_test.go b/server/service/osquery_test.go index 7ce97cff02..38f7f7985b 100644 --- a/server/service/osquery_test.go +++ b/server/service/osquery_test.go @@ -532,7 +532,7 @@ func TestSubmitStatusLogs(t *testing.T) { assert.Equal(t, status, testLogger.logs) } -func TestSubmitResultLogs(t *testing.T) { +func TestSubmitResultLogsToLogDestination(t *testing.T) { ds := new(mock.Store) svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{Logger: log.NewJSONLogger(os.Stdout)}) @@ -594,16 +594,16 @@ func TestSubmitResultLogs(t *testing.T) { require.Len(t, rows, 1) require.Equal(t, uint(999), rows[0].HostID) require.NotZero(t, rows[0].LastFetched) - require.JSONEq(t, `{"hour":"20","minutes":"8"}`, string(rows[0].Data)) + require.JSONEq(t, `{"hour":"20","minutes":"8"}`, string(*rows[0].Data)) case rows[0].QueryID == 444: require.Len(t, rows, 2) require.Equal(t, uint(999), rows[0].HostID) require.NotZero(t, rows[0].LastFetched) - require.JSONEq(t, `{"hour":"20","minutes":"8"}`, string(rows[0].Data)) + require.JSONEq(t, `{"hour":"20","minutes":"8"}`, string(*rows[0].Data)) require.Equal(t, uint(999), rows[1].HostID) require.Equal(t, uint(444), rows[1].QueryID) require.NotZero(t, rows[1].LastFetched) - require.JSONEq(t, `{"hour":"21","minutes":"9"}`, string(rows[1].Data)) + require.JSONEq(t, `{"hour":"21","minutes":"9"}`, string(*rows[1].Data)) } return nil } @@ -694,33 +694,13 @@ func TestSaveResultLogsToQueryReports(t *testing.T) { { QueryName: "pack/Global/Uptime", OsqueryHostID: "1379f59d98f4", - Snapshot: []json.RawMessage{ - json.RawMessage(`{"hour":"20","minutes":"8"}`), + Snapshot: []*json.RawMessage{ + ptr.RawMessage(json.RawMessage(`{"hour":"20","minutes":"8"}`)), }, UnixTime: 1484078931, }, } - queriesDBData := map[string]*fleet.Query{ - "pack/Global/Uptime": { - ID: 1, - DiscardData: false, - Logging: fleet.LoggingSnapshot, - }, - } - - // Result not saved if result is not a snapshot - notSnapshotResult := []*fleet.ScheduledQueryResult{ - { - QueryName: "pack/Global/Uptime", - OsqueryHostID: "1379f59d98f4", - Snapshot: []json.RawMessage{}, - UnixTime: 1484078931, - }, - } - serv.saveResultLogsToQueryReports(ctx, notSnapshotResult, queriesDBData) - assert.False(t, ds.OverwriteQueryResultRowsFuncInvoked) - // Results not saved if DiscardData is true in Query discardDataFalse := map[string]*fleet.Query{ "pack/Global/Uptime": { @@ -750,6 +730,108 @@ func TestSaveResultLogsToQueryReports(t *testing.T) { require.True(t, ds.OverwriteQueryResultRowsFuncInvoked) } +func TestSubmitResultLogsToQueryResultsWithEmptySnapShot(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil) + + host := fleet.Host{ + ID: 999, + } + ctx = hostctx.NewContext(ctx, &host) + + logs := []string{ + `{"snapshot":[],"action":"snapshot","name":"pack/Global/query_no_rows","hostIdentifier":"1379f59d98f4","calendarTime":"Tue Jan 10 20:08:51 2017 UTC","unixTime":1484078931,"decorations":{"host_uuid":"EB714C9D-C1F8-A436-B6DA-3F853C5502EA"}}`, + } + + logJSON := fmt.Sprintf("[%s]", strings.Join(logs, ",")) + var results []json.RawMessage + err := json.Unmarshal([]byte(logJSON), &results) + require.NoError(t, err) + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{ + ServerSettings: fleet.ServerSettings{ + QueryReportsDisabled: false, + }, + }, nil + } + + ds.QueryByNameFunc = func(ctx context.Context, teamID *uint, name string) (*fleet.Query, error) { + return &fleet.Query{ + ID: 1, + DiscardData: false, + Logging: fleet.LoggingSnapshot, + }, nil + } + + ds.ResultCountForQueryFunc = func(ctx context.Context, queryID uint) (int, error) { + return 0, nil + } + + ds.OverwriteQueryResultRowsFunc = func(ctx context.Context, rows []*fleet.ScheduledQueryResultRow) error { + require.Len(t, rows, 1) + require.Equal(t, uint(999), rows[0].HostID) + require.NotZero(t, rows[0].LastFetched) + require.Nil(t, rows[0].Data) + return nil + } + + err = svc.SubmitResultLogs(ctx, results) + require.NoError(t, err) + assert.True(t, ds.OverwriteQueryResultRowsFuncInvoked) +} + +func TestSubmitResultLogsToQueryResultsDoesNotCountNullDataRows(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil) + + host := fleet.Host{ + ID: 999, + } + ctx = hostctx.NewContext(ctx, &host) + + logs := []string{ + `{"snapshot":[],"action":"snapshot","name":"pack/Global/query_no_rows","hostIdentifier":"1379f59d98f4","calendarTime":"Tue Jan 10 20:08:51 2017 UTC","unixTime":1484078931,"decorations":{"host_uuid":"EB714C9D-C1F8-A436-B6DA-3F853C5502EA"}}`, + } + + logJSON := fmt.Sprintf("[%s]", strings.Join(logs, ",")) + var results []json.RawMessage + err := json.Unmarshal([]byte(logJSON), &results) + require.NoError(t, err) + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{ + ServerSettings: fleet.ServerSettings{ + QueryReportsDisabled: false, + }, + }, nil + } + + ds.QueryByNameFunc = func(ctx context.Context, teamID *uint, name string) (*fleet.Query, error) { + return &fleet.Query{ + ID: 1, + DiscardData: false, + Logging: fleet.LoggingSnapshot, + }, nil + } + + ds.ResultCountForQueryFunc = func(ctx context.Context, queryID uint) (int, error) { + return 0, nil + } + + ds.OverwriteQueryResultRowsFunc = func(ctx context.Context, rows []*fleet.ScheduledQueryResultRow) error { + require.Len(t, rows, 1) + require.Equal(t, uint(999), rows[0].HostID) + require.NotZero(t, rows[0].LastFetched) + require.Nil(t, rows[0].Data) + return nil + } + + err = svc.SubmitResultLogs(ctx, results) + require.NoError(t, err) + assert.True(t, ds.OverwriteQueryResultRowsFuncInvoked) +} + func TestGetQueryNameAndTeamIDFromResult(t *testing.T) { tests := []struct { input string diff --git a/server/service/queries.go b/server/service/queries.go index 094a8519ce..f59c4d46d2 100644 --- a/server/service/queries.go +++ b/server/service/queries.go @@ -126,7 +126,7 @@ func onlyShowObserverCanRunQueries(user *fleet.User, teamID *uint) bool { } //////////////////////////////////////////////////////////////////////////////// -// Get query report +// Query Reports //////////////////////////////////////////////////////////////////////////////// type getQueryReportRequest struct { @@ -186,6 +186,23 @@ func (svc *Service) GetQueryReportResults(ctx context.Context, id uint) ([]fleet return queryReportResults, nil } +func (svc *Service) QueryReportIsClipped(ctx context.Context, queryID uint) (bool, error) { + query, err := svc.ds.Query(ctx, queryID) + if err != nil { + setAuthCheckedOnPreAuthErr(ctx) + return false, ctxerr.Wrap(ctx, err, "get query from datastore") + } + if err := svc.authz.Authorize(ctx, query, fleet.ActionRead); err != nil { + return false, err + } + + count, err := svc.ds.ResultCountForQuery(ctx, queryID) + if err != nil { + return false, err + } + return count >= fleet.MaxQueryReportRows, nil +} + //////////////////////////////////////////////////////////////////////////////// // Create Query //////////////////////////////////////////////////////////////////////////////// diff --git a/server/service/queries_test.go b/server/service/queries_test.go index 5d1815d703..e7580c57f1 100644 --- a/server/service/queries_test.go +++ b/server/service/queries_test.go @@ -447,6 +447,11 @@ func TestQueryAuth(t *testing.T) { } return nil, newNotFoundError() } + + ds.ResultCountForQueryFunc = func(ctx context.Context, queryID uint) (int, error) { + return 0, nil + } + ds.SaveQueryFunc = func(ctx context.Context, query *fleet.Query, shouldDiscardResults bool) error { return nil } @@ -660,6 +665,9 @@ func TestQueryAuth(t *testing.T) { _, err = svc.GetQuery(ctx, tt.qid) checkAuthErr(t, tt.shouldFailRead, err) + _, err = svc.QueryReportIsClipped(ctx, tt.qid) + checkAuthErr(t, tt.shouldFailRead, err) + _, err = svc.ListQueries(ctx, fleet.ListOptions{}, query.TeamID, nil) checkAuthErr(t, tt.shouldFailRead, err) @@ -682,3 +690,31 @@ func TestQueryAuth(t *testing.T) { }) } } + +func TestQueryReportIsClipped(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil) + viewerCtx := viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{ + ID: 1, + GlobalRole: ptr.String(fleet.RoleAdmin), + }}) + + ds.QueryFunc = func(ctx context.Context, queryID uint) (*fleet.Query, error) { + return &fleet.Query{}, nil + } + ds.ResultCountForQueryFunc = func(ctx context.Context, queryID uint) (int, error) { + return 0, nil + } + + isClipped, err := svc.QueryReportIsClipped(viewerCtx, 1) + require.NoError(t, err) + require.False(t, isClipped) + + ds.ResultCountForQueryFunc = func(ctx context.Context, queryID uint) (int, error) { + return fleet.MaxQueryReportRows, nil + } + + isClipped, err = svc.QueryReportIsClipped(viewerCtx, 1) + require.NoError(t, err) + require.True(t, isClipped) +}