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 (
+
+
+ <>
+ Show query
+ >
+
+
+ <>
+ Export results
+
+ >
+
+
+ );
+ }, [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)
+}