Per Host Query Reports (#15591)

## Addresses #14415 
<img width="1281" alt="image"
src="https://github.com/fleetdm/fleet/assets/61553566/568f367b-abaa-4c27-b68d-763af9c6a104">
<img width="1277" alt="image"
src="https://github.com/fleetdm/fleet/assets/61553566/07043268-d68a-46c7-94cf-47adb2b86ae3">

- [X] Changes file added for user-visible changes in `changes/` 
- [X] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)
- [X] Added/updated tests
- [X] Manual QA for all new/changed functionality

co-authored by @mostlikelee and @jacobshandling
This commit is contained in:
Jacob Shandling 2023-12-12 14:32:41 -08:00 committed by GitHub
commit 0967256cce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
129 changed files with 2452 additions and 827 deletions

View file

@ -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

View file

@ -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) {

View file

@ -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 (
<Link className={backLinkClass} to={path || ""} onClick={onClick}>
<Link
className={classnames(baseClass, className)}
to={path || ""}
onClick={onClick}
>
<>
<Icon
name="chevron-left"
className={`${baseClass}__back-icon`}
color="core-fleet-blue"
/>
<Icon name="chevron-left" color="core-fleet-blue" />
<span>{text}</span>
</>
</Link>

View file

@ -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;
}

View file

@ -35,8 +35,12 @@ const EmptyTable = ({
)}
<div className={`${baseClass}__inner`}>
{header && <h3>{header}</h3>}
{info && <p>{info}</p>}
{additionalInfo && <p>{additionalInfo}</p>}
{info && <div className={`${baseClass}__info`}>{info}</div>}
{additionalInfo && (
<div className={`${baseClass}__additional-info`}>
{additionalInfo}
</div>
)}
</div>
{primaryButton && (
<div className={`${baseClass}__cta-buttons`}>

View file

@ -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;

View file

@ -63,7 +63,7 @@ const TargetsInput = ({
{isActiveSearch && (
<div className={`${baseClass}__hosts-search-dropdown`}>
<TableContainer
columns={resultsDropdownTableHeaders}
columnConfigs={resultsDropdownTableHeaders}
data={dropdownHosts}
isLoading={isTargetsLoading}
resultsTitle=""
@ -94,7 +94,7 @@ const TargetsInput = ({
)}
<div className={`${baseClass}__hosts-selected-table`}>
<TableContainer
columns={selectedTableHeaders}
columnConfigs={selectedTableHeaders}
data={targetedHosts}
isLoading={false}
resultsTitle=""

View file

@ -3,6 +3,7 @@ import ReactTooltip from "react-tooltip";
import { isEmpty } from "lodash";
import Icon from "components/Icon";
import { COLORS } from "styles/var/colors";
const baseClass = "issue-cell";
@ -32,7 +33,7 @@ const IssueCell = ({ issues, rowId }: IIssueCellProps<any>): JSX.Element => {
<ReactTooltip
place="top"
effect="solid"
backgroundColor="#3e4771"
backgroundColor={COLORS["tooltip-bg"]}
id={`host-issue__${rowId.toString()}`}
data-html
>

View file

@ -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<T> {
displayName: string;
@ -38,7 +39,7 @@ const LiveQueryIssueCell = ({
<ReactTooltip
place="top"
effect="solid"
backgroundColor="#3e4771"
backgroundColor={COLORS["tooltip-bg"]}
id={`host-issue__${rowId.toString()}`}
data-html
>

View file

@ -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 = ({
<ReactTooltip
place="top"
effect="solid"
backgroundColor="#3e4771"
backgroundColor={COLORS["tooltip-bg"]}
id={`${customIdPrefix || "pill"}__${id?.toString() || tooltipId}`}
data-html
>

View file

@ -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 = ({
<ReactTooltip
place="top"
effect="solid"
backgroundColor="#3e4771"
backgroundColor={COLORS["tooltip-bg"]}
id={tooltipId}
>
{emptyCellTooltipText}

View file

@ -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 = ({
<ReactTooltip
place="top"
effect="solid"
backgroundColor="#3e4771"
backgroundColor={COLORS["tooltip-bg"]}
id={tooltipId}
data-html
className={"truncated-tooltip"} // responsive widths

View file

@ -9,6 +9,7 @@ import SearchField from "components/forms/fields/SearchField";
import Pagination from "components/Pagination";
import Button from "components/buttons/Button";
import Icon from "components/Icon/Icon";
import { COLORS } from "styles/var/colors";
import DataTable from "./DataTable/DataTable";
import TableContainerUtils from "./TableContainerUtils";
@ -32,7 +33,7 @@ interface IRowProps extends Row {
}
interface ITableContainerProps {
columns: any; // TODO: Figure out type
columnConfigs: any; // TODO: Figure out type
data: any; // TODO: Figure out type
isLoading: boolean;
manualSortBy?: boolean;
@ -99,7 +100,7 @@ const DEFAULT_PAGE_SIZE = 20;
const DEFAULT_PAGE_INDEX = 0;
const TableContainer = ({
columns,
columnConfigs,
data,
filters,
isLoading,
@ -367,7 +368,7 @@ const TableContainer = ({
</div>
<ReactTooltip
effect="solid"
backgroundColor="#3e4771"
backgroundColor={COLORS["tooltip-bg"]}
id="search-tooltip"
data-html
>
@ -415,7 +416,7 @@ const TableContainer = ({
>
<DataTable
isLoading={isLoading}
columns={columns}
columns={columnConfigs}
data={data}
filters={filters}
manualSortBy={manualSortBy}

View file

@ -73,10 +73,6 @@
.Select-arrow-zone {
padding-left: 15px;
svg {
position: relative;
top: 3px;
}
}
.Select-multi-value-wrapper {

View file

@ -1,7 +1,3 @@
.view-all-hosts-link {
display: inline-flex;
align-items: center;
padding: $pad-small $pad-xxsmall; // larger clickable area
gap: $pad-xsmall;
white-space: nowrap;
@include table-link;
}

View file

@ -1,12 +1,11 @@
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import { renderWithSetup } from "test/test-utils";
import RevealButton from "./RevealButton";
const SHOW_TEXT = "Show advanced options";
const HIDE_TEXT = "Hide advanced options";
const TOOLTIP_HTML = "Customize logging type and platforms";
const TOOLTIP_CONTENT = "Customize logging type and platforms";
describe("Reveal button", () => {
it("renders show text", async () => {
@ -75,18 +74,18 @@ describe("Reveal button", () => {
});
it("renders tooltip on hover if provided", async () => {
const { user } = renderWithSetup(
render(
<RevealButton
isShowing={false}
hideText={HIDE_TEXT}
showText={SHOW_TEXT}
caretPosition={"before"}
tooltipHtml={TOOLTIP_HTML}
tooltipContent={TOOLTIP_CONTENT}
/>
);
await fireEvent.mouseEnter(screen.getByText(SHOW_TEXT));
expect(screen.getByText(TOOLTIP_HTML)).toBeInTheDocument();
expect(screen.getByText(TOOLTIP_CONTENT)).toBeInTheDocument();
});
});

View file

@ -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<HTMLButtonElement>) => 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 ? (
<TooltipWrapper tipContent={tooltipHtml}>{text}</TooltipWrapper>
const buttonText = tooltipContent ? (
<TooltipWrapper tipContent={tooltipContent}>{text}</TooltipWrapper>
) : (
text
);

View file

@ -75,7 +75,7 @@ const PackQueriesTable = ({
<div className={`${baseClass} body-wrap`}>
{scheduledQueries?.length ? (
<TableContainer
columns={tableHeaders}
columnConfigs={tableHeaders}
data={tableData}
isLoading={isLoadingPackQueries}
defaultSortHeader={"name"}

View file

@ -27,6 +27,9 @@ export interface IQueryStats {
scheduled_query_name: string;
scheduled_query_id: number;
query_name: string;
discard_data: boolean;
last_fetched: string | null; // timestamp
automations_enabled: boolean;
description: string;
pack_name: string;
pack_id: number;

View file

@ -13,6 +13,7 @@ import Button from "components/buttons/Button";
import Icon from "components/Icon";
import ReactTooltip from "react-tooltip";
import PremiumFeatureIconWithTooltip from "components/PremiumFeatureIconWithTooltip";
import { COLORS } from "styles/var/colors";
const baseClass = "activity-item";
@ -919,7 +920,7 @@ const ActivityItem = ({
type="dark"
effect="solid"
id={`activity-${activity.id}`}
backgroundColor="#3e4771"
backgroundColor={COLORS["tooltip-bg"]}
>
{internationalTimeFormat(activityCreatedAt)}
</ReactTooltip>

View file

@ -112,7 +112,7 @@ const Mdm = ({
<TableDataError card />
) : (
<TableContainer
columns={solutionsTableHeaders}
columnConfigs={solutionsTableHeaders}
data={solutionsDataSet}
isLoading={isFetching}
defaultSortHeader={SOLUTIONS_DEFAULT_SORT_HEADER}
@ -132,7 +132,7 @@ const Mdm = ({
<TableDataError card />
) : (
<TableContainer
columns={statusTableHeaders}
columnConfigs={statusTableHeaders}
data={statusDataSet}
isLoading={isFetching}
defaultSortHeader={STATUS_DEFAULT_SORT_HEADER}

View file

@ -69,7 +69,7 @@ const Munki = ({
<TableDataError card />
) : (
<TableContainer
columns={tableHeaders}
columnConfigs={tableHeaders}
data={munkiIssuesData || []}
isLoading={isMacAdminsFetching}
defaultSortHeader={DEFAULT_SORT_HEADER}
@ -96,7 +96,7 @@ const Munki = ({
<TableDataError card />
) : (
<TableContainer
columns={munkiVersionsTableHeaders}
columnConfigs={munkiVersionsTableHeaders}
data={munkiVersionsData || []}
isLoading={isMacAdminsFetching}
defaultSortHeader={DEFAULT_SORT_HEADER}

View file

@ -170,7 +170,7 @@ const OperatingSystems = ({
<TableDataError card />
) : (
<TableContainer
columns={tableHeaders}
columnConfigs={tableHeaders}
data={osInfo?.os_versions || []}
isLoading={isFetching}
defaultSortHeader={DEFAULT_SORT_HEADER}

View file

@ -89,7 +89,7 @@ const Software = ({
<TableDataError />
) : (
<TableContainer
columns={tableHeaders}
columnConfigs={tableHeaders}
data={(isSoftwareEnabled && software?.software) || []}
isLoading={isSoftwareFetching}
defaultSortHeader={SOFTWARE_DEFAULT_SORT_DIRECTION}
@ -116,7 +116,7 @@ const Software = ({
<TableDataError />
) : (
<TableContainer
columns={tableHeaders}
columnConfigs={tableHeaders}
data={(isSoftwareEnabled && software?.software) || []}
isLoading={isSoftwareFetching}
defaultSortHeader={SOFTWARE_DEFAULT_SORT_HEADER}

View file

@ -44,7 +44,7 @@ const DiskEncryptionTable = ({ currentTeamId }: IDiskEncryptionTableProps) => {
return (
<div className={baseClass}>
<TableContainer
columns={tableHeaders}
columnConfigs={tableHeaders}
data={tableData}
resultsTitle="" // TODO: make optional
isLoading={false}

View file

@ -28,7 +28,7 @@ const OSVersionTable = ({
return (
<div className={baseClass}>
<TableContainer
columns={columns}
columnConfigs={columns}
data={osVersionData}
isLoading={isLoading}
resultsTitle=""

View file

@ -9,7 +9,7 @@ import TableContainer from "components/TableContainer";
import EmptyTable from "components/EmptyTable";
import {
TABLE_HEADERS,
COLUMN_CONFIGS,
generateTableData,
} from "./BootstrapPackageTableConfig";
@ -45,7 +45,7 @@ const BootstrapPackageTable = ({
return (
<div className={baseClass}>
<TableContainer
columns={TABLE_HEADERS}
columnConfigs={COLUMN_CONFIGS}
data={tableData}
resultsTitle=""
isLoading={isLoading}

View file

@ -42,7 +42,7 @@ interface IHeaderProps {
};
}
type IDataColumn = {
type IColumnConfig = {
title: string;
Header: ((props: IHeaderProps) => 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",

View file

@ -53,7 +53,7 @@ const SoftwareTitleDetailsTable = ({
<TableContainer
className={baseClass}
resultsTitle={data.length === 1 ? "version" : "versions"}
columns={softwareTableHeaders}
columnConfigs={softwareTableHeaders}
data={data}
isLoading={isLoading}
emptyComponent={NoVersionsDetected}

View file

@ -259,7 +259,7 @@ const SoftwareTitles = ({
return (
<div className={baseClass}>
<TableContainer
columns={softwareTableHeaders}
columnConfigs={softwareTableHeaders}
data={softwareData?.software_titles || []}
isLoading={isSoftwareLoading}
resultsTitle={"items"}

View file

@ -100,7 +100,7 @@ const SoftwareVersionDetailsPage = ({
{softwareVersion?.vulnerabilities?.length ? (
<div className="vuln-table">
<TableContainer
columns={tableHeaders}
columnConfigs={tableHeaders}
data={softwareVersion.vulnerabilities}
defaultSortHeader={isPremiumTier ? "epss_probability" : "cve"}
defaultSortDirection={"desc"}

View file

@ -273,7 +273,7 @@ const SoftwareVersions = ({
<div className={baseClass}>
<div className={baseClass}>
<TableContainer
columns={softwareTableHeaders}
columnConfigs={softwareTableHeaders}
data={softwareVersionsData?.software || []}
isLoading={isSoftwareVersionsLoading}
resultsTitle={"items"}

View file

@ -31,6 +31,7 @@ import validUrl from "components/forms/validators/valid_url";
import { IWebhookSoftwareVulnerabilities } from "interfaces/webhook";
import useDeepEffect from "hooks/useDeepEffect";
import { isEmpty, omit } from "lodash";
import { COLORS } from "styles/var/colors";
import PreviewPayloadModal from "../PreviewPayloadModal";
import PreviewTicketModal from "../PreviewTicketModal";
@ -519,7 +520,7 @@ const ManageAutomationsModal = ({
className={`save-automation-button-tooltip`}
place="bottom"
effect="solid"
backgroundColor="#3e4771"
backgroundColor={COLORS["tooltip-bg"]}
id="save-automation-button"
data-html
>

View file

@ -407,7 +407,7 @@ const Integrations = (): JSX.Element => {
<TableDataError />
) : (
<TableContainer
columns={tableHeaders}
columnConfigs={tableHeaders}
data={tableData}
isLoading={isLoadingIntegrations}
defaultSortHeader={"name"}

View file

@ -15,6 +15,7 @@ import InputField from "components/forms/fields/InputField";
import validUrl from "components/forms/validators/valid_url";
import Spinner from "components/Spinner";
import { COLORS } from "styles/var/colors";
const baseClass = "integration-form";
@ -300,7 +301,7 @@ const IntegrationForm = ({
className={`add-integration-tooltip`}
place="bottom"
effect="solid"
backgroundColor="#3e4771"
backgroundColor={COLORS["tooltip-bg"]}
id="add-integration-button"
data-html
>

View file

@ -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 <Spinner />;
}
const tableHeaders = generateTableHeaders(onActionSelection);
const columnConfigs = generateColumnConfigs(onActionSelection);
return (
<div className={baseClass}>
@ -431,7 +431,7 @@ const MembersPage = ({ location, router }: IMembersPageProps): JSX.Element => {
) : (
<TableContainer
resultsTitle={"members"}
columns={tableHeaders}
columnConfigs={columnConfigs}
data={members || []}
isLoading={isLoadingMembers}
defaultSortHeader={"name"}

View file

@ -8,6 +8,7 @@ import { ITeam } from "interfaces/team";
import { IDropdownOption } from "interfaces/dropdownOption";
import stringUtils from "utilities/strings";
import TooltipWrapper from "components/TooltipWrapper";
import { COLORS } from "styles/var/colors";
interface IHeaderProps {
column: {
@ -57,7 +58,7 @@ export interface IMembersTableData {
// NOTE: cellProps come from react-table
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
const generateTableHeaders = (
const generateColumnConfigs = (
actionSelectHandler: (value: string, user: IUser) => 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 };

View file

@ -270,7 +270,7 @@ const TeamManagementPage = (): JSX.Element => {
<TableDataError />
) : (
<TableContainer
columns={tableHeaders}
columnConfigs={tableHeaders}
data={tableData}
isLoading={isFetchingTeams}
defaultSortHeader={"name"}

View file

@ -535,7 +535,7 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => {
return (
<TableContainer
columns={tableHeaders}
columnConfigs={tableHeaders}
data={tableData}
isLoading={loadingTableData}
defaultSortHeader={"name"}

View file

@ -11,6 +11,7 @@ import { IUser, UserRole } from "interfaces/user";
import { IDropdownOption } from "interfaces/dropdownOption";
import { generateRole, generateTeam, greyCell } from "utilities/helpers";
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
import { COLORS } from "styles/var/colors";
import DropdownCell from "../../../../../components/TableContainer/DataTable/DropdownCell";
interface IHeaderProps {
@ -98,7 +99,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
>

View file

@ -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[] = [
</span>
<ReactTooltip
effect="solid"
backgroundColor="#3e4771"
backgroundColor={COLORS["tooltip-bg"]}
id={`host__${cellProps.row.original.id}`}
data-html
>
@ -355,7 +356,7 @@ const allHostTableHeaders: IDataColumn[] = [
</span>
<ReactTooltip
effect="solid"
backgroundColor="#3e4771"
backgroundColor={COLORS["tooltip-bg"]}
id={`device_mapping__${cellProps.row.original.id}`}
data-html
clickable
@ -467,7 +468,7 @@ const allHostTableHeaders: IDataColumn[] = [
<ReactTooltip
place="top"
effect="solid"
backgroundColor="#3e4771"
backgroundColor={COLORS["tooltip-bg"]}
id={`public-ip__${cellProps.row.original.id}`}
data-html
clickable

View file

@ -1466,7 +1466,7 @@ const ManageHostsPage = ({
return (
<TableContainer
resultsTitle="hosts"
columns={tableColumns}
columnConfigs={tableColumns}
data={hostsData?.hosts || []}
isLoading={isLoadingHosts || isLoadingHostsCount || isLoadingPolicy}
manualSortBy

View file

@ -7,6 +7,7 @@ import PremiumFeatureIconWithTooltip from "components/PremiumFeatureIconWithTool
import Icon from "components/Icon";
import { IconNames } from "components/icons";
import { COLORS } from "styles/var/colors";
interface IFilterPillProps {
label: string;
@ -73,7 +74,7 @@ const FilterPill = ({
role="tooltip"
place="bottom"
effect="solid"
backgroundColor="#3e4771"
backgroundColor={COLORS["tooltip-bg"]}
id={`filter-pill-tooltip-${label}`}
data-html
>

View file

@ -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 = () => {

View file

@ -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 {

View file

@ -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 <Spinner />;
}
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 (
<MainContent className={baseClass}>
<div className={`${baseClass}__wrapper`}>
<>
<HostDetailsBanners
hostMdmEnrollmentStatus={host?.mdm.enrollment_status}
hostPlatform={host?.platform}
@ -768,10 +769,14 @@ const HostDetailsPage = ({
)}
</TabPanel>
<TabPanel>
<ScheduleCard
isChromeOSHost={host?.platform === "chrome"}
<QueriesCard
hostId={host.id}
router={router}
isChromeOSHost={host.platform === "chrome"}
schedule={schedule}
isLoading={isLoadingHost}
queryReportsDisabled={
config?.server_settings?.query_reports_disabled
}
/>
{canViewPacks && (
<PacksCard packsState={packsState} isLoading={isLoadingHost} />
@ -854,7 +859,7 @@ const HostDetailsPage = ({
onCancel={onCancelScriptDetailsModal}
/>
)}
</div>
</>
</MainContent>
);
};

View file

@ -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;

View file

@ -45,24 +45,27 @@ const HostDetailsBanners = ({
mdmName === "Fleet" &&
diskEncryptionStatus === "action_required";
return (
<div className={baseClass}>
{showTurnOnMdmInfoBanner && (
<InfoBanner color="yellow">
To change settings and install software, ask the end user to follow
the <strong>Turn on MDM</strong> instructions on their{" "}
<strong>My device</strong> page.
</InfoBanner>
)}
{showDiskEncryptionUserActionRequired && (
<InfoBanner color="yellow">
Disk encryption: Requires action from the end user. Ask the end user
to follow <b>Disk encryption</b> instructions on their{" "}
<b>My device</b> page.
</InfoBanner>
)}
</div>
);
if (showTurnOnMdmInfoBanner || showDiskEncryptionUserActionRequired) {
return (
<div className={baseClass}>
{showTurnOnMdmInfoBanner && (
<InfoBanner color="yellow">
To change settings and install software, ask the end user to follow
the <strong>Turn on MDM</strong> instructions on their{" "}
<strong>My device</strong> page.
</InfoBanner>
)}
{showDiskEncryptionUserActionRequired && (
<InfoBanner color="yellow">
Disk encryption: Requires action from the end user. Ask the end user
to follow <b>Disk encryption</b> instructions on their{" "}
<b>My device</b> page.
</InfoBanner>
)}
</div>
);
}
return null;
};
export default HostDetailsBanners;

View file

@ -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<string, string>[];
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<Row[]>([]);
const columnConfigs = generateColumnConfigs(rows);
const renderTableButtons = useCallback(() => {
const onExportQueryResults = (evt: React.MouseEvent<HTMLButtonElement>) => {
evt.preventDefault();
FileSaver.saveAs(
generateCSVQueryResults(
filteredResults,
generateCSVFilename(
queryName && hostName
? `'${queryName}' query report results for host '${hostName}'`
: DEFAULT_CSV_TITLE
),
columnConfigs
)
);
};
return (
<div className={`${baseClass}__results-cta`}>
<Button
className={`${baseClass}__show-query-btn`}
onClick={onShowQuery}
variant="text-icon"
>
<>
Show query <Icon name="eye" />
</>
</Button>
<Button
className={`${baseClass}__export-btn`}
onClick={onExportQueryResults}
variant="text-icon"
>
<>
Export results
<Icon name="download" color="core-fleet-blue" />
</>
</Button>
</div>
);
}, [onShowQuery, filteredResults, queryName, hostName, columnConfigs]);
const renderEmptyState = useCallback(() => {
// rows.length === 0
if (!lastFetched) {
// collecting results
return (
<EmptyTable
className={`${baseClass}__collecting-results`}
graphicName="collecting-results"
header="Collecting results..."
info={`Fleet is collecting query results from ${hostName}. Check back later.`}
/>
);
}
if (reportClipped) {
return (
<EmptyTable
className={`${baseClass}__report-clipped`}
graphicName="empty-software"
header="Report clipped"
info="This query has paused reporting in Fleet, and no results were saved for this host."
/>
);
}
return (
// nothing to report
<EmptyTable
className={`${baseClass}__nothing-to-report`}
graphicName="empty-software"
header="Nothing to report"
info={`This query has run on ${hostName}, but returned no data for this host.`}
/>
);
}, [lastFetched, hostName, reportClipped]);
const renderCount = useCallback(() => {
const count = filteredResults.length;
return (
<div className={`${baseClass}__results-count-and-last-fetched`}>
<span>{`${count} result${count === 1 ? "" : "s"}`}</span>
<span className="last-fetched">
Last fetched{" "}
<HumanTimeDiffWithFleetLaunchCutoff timeString={lastFetched ?? ""} />
</span>
</div>
);
}, [filteredResults.length, lastFetched]);
const renderTableInfo = useCallback(
() => (
<div className={`${baseClass}__query-info`}>
<h2>{queryName}</h2>
<h3>{queryDescription}</h3>
</div>
),
[queryDescription, queryName]
);
if (isLoading) {
return <Spinner />;
}
return (
<div className={`${baseClass} section`}>
{renderTableInfo()}
{rows.length === 0 ? (
renderEmptyState()
) : (
<TableContainer
isLoading={isLoading}
columnConfigs={columnConfigs}
data={rows}
renderCount={renderCount}
isClientSidePagination
isClientSideFilter
isMultiColumnFilter
showMarkAllPages={false}
isAllPagesSelected={false}
resultsTitle="results"
customControl={renderTableButtons}
setExportRows={setFilteredResults}
emptyComponent={() => null}
defaultSortHeader={columnConfigs[0].title}
defaultSortDirection="asc"
/>
)}
</div>
);
};
export default HQRTable;

View file

@ -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<TableInstance> & {
column: ColumnInstance & IDataColumn;
};
type ICellProps = CellProps<TableInstance>;
interface IDataColumn extends ColumnInterface {
title?: string;
accessor: string;
}
const generateColumnConfigs = (rows: Record<string, string>[]) =>
// 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) => (
<HeaderCell
value={
// Sentence case last fetched
headerProps.column.title === "last_fetched"
? "Last fetched"
: headerProps.column.title || headerProps.column.id
}
isSortedDesc={headerProps.column.isSortedDesc}
/>
),
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;

View file

@ -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;
}
}

View file

@ -0,0 +1 @@
export { default } from "./HQRTable";

View file

@ -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<IGetHQRResponse, Error>(
[hostId, queryId],
() => hqrAPI.load(hostId, queryId),
{
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
}
);
const {
isLoading: queryLoading,
data: queryResponse,
error: queryError,
} = useQuery<IGetQueryResponse, Error, ISchedulableQuery>(
["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 (
<div className={`${baseClass}__header`}>
<div className={`${baseClass}__header__row1`}>
<BackLink
text="Back to host details"
path={PATHS.HOST_QUERIES(hostId)}
/>
</div>
<div className={`${baseClass}__header__row2`}>
{!hqrError && <h1 className="host-name">{hostName}</h1>}
<Link
// to and onClick seem redundant
to={fullReportPath}
onClick={() => {
browserHistory.push(fullReportPath);
}}
className={`${baseClass}__direction-link`}
>
<>
<span>View full query report</span>
<Icon name="chevron-right" color="core-fleet-blue" />
</>
</Link>
</div>
</div>
);
}, [queryId, hostId, hqrError, hostName]);
return (
<MainContent className={baseClass}>
{isLoading ? (
<Spinner />
) : (
<>
<HQRHeader />
<HQRTable
queryName={queryName}
queryDescription={queryDescription}
hostName={hostName}
rows={rows}
reportClipped={reportClipped}
lastFetched={lastFetched}
onShowQuery={() => setShowQuery(true)}
isLoading={false}
/>
{showQuery && (
<ShowQueryModal
query={querySQL}
onCancel={() => setShowQuery(false)}
/>
)}
</>
)}
</MainContent>
);
};
export default HostQueryReport;

View file

@ -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;
}
}

View file

@ -0,0 +1 @@
export { default } from "./HostQueryReport";

View file

@ -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 = ({
<ReactTooltip
place="top"
effect="solid"
backgroundColor="#3e4771"
backgroundColor={COLORS["tooltip-bg"]}
id={tooltipId}
data-html
>

View file

@ -15,7 +15,7 @@ const OSSettingsTable = ({ tableData }: IOSSettingsTableProps) => {
<TableContainer
resultsTitle="settings"
defaultSortHeader="name"
columns={tableHeaders}
columnConfigs={tableHeaders}
data={tableData}
emptyComponent={"symbol"}
isLoading={false}

View file

@ -3,6 +3,7 @@ import ReactTooltip from "react-tooltip";
import { IconNames } from "components/icons";
import Icon from "components/Icon";
import Button from "components/buttons/Button";
import { COLORS } from "styles/var/colors";
const baseClass = "profile-status-indicator";
@ -43,7 +44,7 @@ const ProfileStatusIndicator = ({
<ReactTooltip
place={tooltip.position ?? "bottom"}
effect="solid"
backgroundColor="#3e4771"
backgroundColor={COLORS["tooltip-bg"]}
id={`${indicatorText}-tooltip`}
data-html
>
@ -81,7 +82,7 @@ const ProfileStatusIndicator = ({
<ReactTooltip
place={tooltip.position ?? "bottom"}
effect="solid"
backgroundColor="#3e4771"
backgroundColor={COLORS["tooltip-bg"]}
id={`${indicatorText}-tooltip`}
data-html
>

View file

@ -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;
}
}
}

View file

@ -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 = ({
<ReactTooltip
place="bottom"
effect="solid"
backgroundColor="#3e4771"
backgroundColor={COLORS["tooltip-bg"]}
id={"public-ip-tooltip"}
data-html
clickable

View file

@ -18,6 +18,7 @@ import PremiumFeatureIconWithTooltip from "components/PremiumFeatureIconWithTool
import { humanHostMemory, wrapFleetHelper } from "utilities/helpers";
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
import StatusIndicator from "components/StatusIndicator";
import { COLORS } from "styles/var/colors";
import OSSettingsIndicator from "./OSSettingsIndicator";
import HostSummaryIndicator from "./HostSummaryIndicator";
@ -141,7 +142,7 @@ const HostSummary = ({
place="top"
effect="solid"
id="refetch-tooltip"
backgroundColor="#3e4771"
backgroundColor={COLORS["tooltip-bg"]}
>
<span className={`${baseClass}__tooltip-text`}>
You cant fetch data from <br /> an offline host.
@ -168,7 +169,7 @@ const HostSummary = ({
<ReactTooltip
place="bottom"
effect="solid"
backgroundColor="#3e4771"
backgroundColor={COLORS["tooltip-bg"]}
id="host-issue-count"
data-html
>
@ -340,10 +341,10 @@ const HostSummary = ({
: titleData.display_name || DEFAULT_EMPTY_CELL_VALUE}
</h1>
<p className="last-fetched">
<div className="last-fetched">
{"Last fetched"} {lastFetched}
&nbsp;
</p>
</div>
{renderRefetch()}
</div>
</div>

View file

@ -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 = ({
<ReactTooltip
place="bottom"
effect="solid"
backgroundColor="#3e4771"
backgroundColor={COLORS["tooltip-bg"]}
id={`${baseClass}-tooltip`}
data-html
>

View file

@ -30,7 +30,7 @@ const MunkiIssuesTable = ({
{munkiIssues?.length ? (
<div className={deviceType || ""}>
<TableContainer
columns={tableHeaders}
columnConfigs={tableHeaders}
data={tableMunkiIssues || []}
isLoading={isLoading}
defaultSortHeader={"name"}

View file

@ -44,7 +44,7 @@ const Packs = ({ packsState, isLoading }: IPacksProps): JSX.Element => {
{!!pack.query_stats.length && (
<div className={`${wrapperClassName}`}>
<TableContainer
columns={tableHeaders}
columnConfigs={tableHeaders}
data={generatePackDataSet(pack.query_stats)}
isLoading={isLoading}
onQueryChange={() => null}

View file

@ -86,7 +86,7 @@ const Policies = ({
</InfoBanner>
)}
<TableContainer
columns={tableHeaders}
columnConfigs={tableHeaders}
data={generatePolicyDataSet(policies)}
isLoading={isLoading}
defaultSortHeader={"name"}

View file

@ -0,0 +1,118 @@
import React, { useCallback, useMemo } from "react";
import { IQueryStats } from "interfaces/query_stats";
import TableContainer from "components/TableContainer";
import EmptyTable from "components/EmptyTable";
import CustomLink from "components/CustomLink";
import PATHS from "router/paths";
import { InjectedRouter } from "react-router";
import { Row } from "react-table";
import {
generateColumnConfigs,
generateDataSet,
} from "./HostQueriesTableConfig";
const baseClass = "host-queries";
interface IHostQueriesProps {
hostId: number;
schedule?: IQueryStats[];
isChromeOSHost: boolean;
queryReportsDisabled?: boolean;
router: InjectedRouter;
}
interface IHostQueriesRowProps extends Row {
original: {
id?: number;
should_link_to_hqr?: boolean;
};
}
const HostQueries = ({
hostId,
schedule,
isChromeOSHost,
queryReportsDisabled,
router,
}: IHostQueriesProps): JSX.Element => {
const renderEmptyQueriesTab = () => {
if (isChromeOSHost) {
return (
<EmptyTable
header="Scheduled queries are not supported for this host"
info={
<>
<span>Interested in collecting data from your Chromebooks? </span>
<CustomLink
url="https://www.fleetdm.com/contact"
text="Let us know"
newTab
/>
</>
}
/>
);
}
return (
<EmptyTable
header="No queries are scheduled to run on this host"
info={
<>
Expecting to see queries? Try selecting <b>Refetch</b> 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 (
<div className="section section--host-queries">
<p className="section__header">Queries</p>
{!schedule || !schedule.length || isChromeOSHost ? (
renderEmptyQueriesTab()
) : (
<div>
<TableContainer
columnConfigs={columnConfigs}
data={tableData}
onQueryChange={() => 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 }}
/>
</div>
)}
</div>
);
};
export default HostQueries;

View file

@ -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<IQueryStats> {
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) => (
<TextCell value={cellProps.cell.value} />
),
},
{
Header: () => {
return (
<TooltipWrapper
tipContent={
<>
This is the performance <br />
impact on this host.
</>
}
>
Performance impact
</TooltipWrapper>
);
},
disableSortBy: true,
accessor: "performance",
Cell: (cellProps: IPillCellProps) => {
const baseClass = "performance-cell";
return (
<span className={baseClass}>
<PillCell
value={cellProps.cell.value}
customIdPrefix="query-perf-pill"
hostDetails
/>
{!queryReportsDisabled &&
cellProps.row.original.should_link_to_hqr && (
<Icon
name="chevron-right"
className={`${baseClass}__link-icon`}
color="core-fleet-blue"
/>
)}
</span>
);
},
},
];
// 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) => (
<ReportUpdatedCell {...cellProps.row.original} />
),
});
}
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 };

View file

@ -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(
<ReportUpdatedCell
interval={1000}
discard_data
automations_enabled
should_link_to_hqr={false}
/>
);
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(
<ReportUpdatedCell
interval={1000}
discard_data={false}
automations_enabled={false}
should_link_to_hqr
/>
);
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(
<ReportUpdatedCell
interval={1000}
discard_data={false}
automations_enabled={false}
should_link_to_hqr
last_fetched="2023-11-29T15:20:02Z"
/>
);
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(
<ReportUpdatedCell
interval={0}
discard_data={false}
automations_enabled={false}
should_link_to_hqr
last_fetched="2023-11-29T15:20:02Z"
/>
);
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();
});
});

View file

@ -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 (
<TextCell
greyed
classes={`${baseClass}__value`}
emptyCellTooltipText={
<>
Results from this query are not reported in Fleet.
<br />
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 (
<TextCell
value="Never"
formatter={(val) => (
<>
<span data-tip data-for={tipId}>
{val}
</span>
<ReactTooltip
id={tipId}
effect="solid"
backgroundColor={COLORS["tooltip-bg"]}
place="top"
>
This query has not run on this host.
</ReactTooltip>
</>
)}
greyed
classes={`${baseClass}__value`}
/>
);
}
}
// render with link to cached results (link handled by clickable parent row)
return (
<>
<TextCell
// last_fetched will be truthy at this point
value={{ timeString: last_fetched ?? "" }}
formatter={HumanTimeDiffWithFleetLaunchCutoff}
classes={`${baseClass}__value`}
/>
</>
);
};
return (
<span className={baseClass}>
{renderCellValue()}
{should_link_to_hqr && (
// actual link functionality handled by clickable parent row
<span
className={`${baseClass}__link`}
title="link to host query report"
>
<span className={`${baseClass}__link-text`}>View report</span>
<Icon
name="chevron-right"
className={`${baseClass}__link-icon`}
color="core-fleet-blue"
/>
</span>
)}
</span>
);
};
export default ReportUpdatedCell;

View file

@ -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;
}
}

View file

@ -0,0 +1 @@
export { default } from "./ReportUpdatedCell";

View file

@ -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;
}
}
}
}
}
}
}

View file

@ -0,0 +1 @@
export { default } from "./HostQueries";

View file

@ -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 (
<EmptyTable
header="Scheduled queries are not supported for this host"
info={
<>
<span>Interested in collecting data from your Chromebooks? </span>
<CustomLink
url="https://www.fleetdm.com/contact"
text="Let us know"
newTab
/>
</>
}
/>
);
}
return (
<EmptyTable
header="No queries are scheduled for this host"
info="Expecting to see queries? Try selecting Refetch to ask this host
to report new vitals."
/>
);
};
return (
<div className="section section--schedule">
<p className="section__header">Schedule</p>
{!schedule || !schedule.length || isChromeOSHost ? (
renderEmptyScheduleTab()
) : (
<div className={`${wrapperClassName}`}>
<TableContainer
columns={tableHeaders}
data={generateDataSet(schedule)}
isLoading={isLoading}
onQueryChange={() => null}
resultsTitle={"queries"}
defaultSortHeader={"scheduled_query_name"}
defaultSortDirection={"asc"}
showMarkAllPages={false}
isAllPagesSelected={false}
emptyComponent={() => <></>}
disablePagination
disableCount
/>
</div>
)}
</div>
);
};
export default Schedule;

View file

@ -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<IQueryStats> {
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) => (
<TextCell value={cellProps.cell.value} />
),
},
{
title: "Frequency",
Header: "Frequency",
disableSortBy: true,
accessor: "frequency",
Cell: (cellProps: ICellProps) => (
<TextCell value={cellProps.cell.value} />
),
},
{
Header: () => {
return (
<TooltipWrapper
tipContent={
<>
This is the performance <br />
impact on this host.
</>
}
>
Performance impact
</TooltipWrapper>
);
},
disableSortBy: true,
accessor: "performance",
Cell: (cellProps: IPillCellProps) => (
<PillCell
value={cellProps.cell.value}
customIdPrefix="query-perf-pill"
hostDetails
/>
),
},
];
};
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 };

View file

@ -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;
}
}
}
}
}

View file

@ -1 +0,0 @@
export { default } from "./Schedule";

View file

@ -113,7 +113,7 @@ const Scripts = ({
emptyComponent={() => <></>}
showMarkAllPages={false}
isAllPagesSelected={false}
columns={scriptColumnConfigs}
columnConfigs={scriptColumnConfigs}
data={data}
isLoading={isLoadingScriptData}
onQueryChange={onQueryChange}

View file

@ -221,7 +221,7 @@ const SoftwareTable = ({
<div className={deviceType || ""}>
<TableContainer
resultsTitle="software items"
columns={tableHeaders}
columnConfigs={tableHeaders}
data={tableSoftware || []}
filters={{
global: searchQuery,

View file

@ -293,7 +293,7 @@ export const generateSoftwareTableHeaders = ({
</span>
<ReactTooltip
effect="solid"
backgroundColor="#3e4771"
backgroundColor={COLORS["tooltip-bg"]}
id={`vulnerabilities__${cellProps.row.original.id}`}
data-html
>
@ -333,7 +333,7 @@ export const generateSoftwareTableHeaders = ({
</span>
<ReactTooltip
effect="solid"
backgroundColor="#3e4771"
backgroundColor={COLORS["tooltip-bg"]}
id={`last_used__${cellProps.row.original.id}`}
className="last_used_tooltip"
data-tip-disable={hasLastUsed}

View file

@ -51,7 +51,7 @@ const Users = ({
<p className="section__header">Users</p>
{users?.length ? (
<TableContainer
columns={tableHeaders}
columnConfigs={tableHeaders}
data={usersState}
isLoading={isLoading}
defaultSortHeader={"username"}

View file

@ -104,7 +104,7 @@ const PacksTable = ({
<div className={`${baseClass}`}>
<TableContainer
resultsTitle={"packs"}
columns={tableHeaders}
columnConfigs={tableHeaders}
data={generateDataSet(filteredPacks)}
isLoading={isLoading}
defaultSortHeader={"pack"}

View file

@ -767,9 +767,13 @@ const ManagePolicyPage = ({
globalPoliciesCount
)}
caretPosition={"before"}
tooltipHtml={`"All teams" policies are checked ${(
<br />
)} for this team's hosts.`}
tooltipContent={
<>
&quot;All teams&quot; policies are checked
<br />
for this team&apos;s hosts.
</>
}
onClick={toggleShowInheritedPolicies}
/>
)}

View file

@ -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 = ({
>
<TableContainer
resultsTitle="policies"
columns={generateTableHeaders(
columnConfigs={generateTableHeaders(
{
selectedTeamId: currentTeam?.id,
canAddOrDeletePolicy,

View file

@ -192,7 +192,7 @@ const generateTableHeaders = (
<ReactTooltip
place="bottom"
effect="solid"
backgroundColor="#3e4771"
backgroundColor={COLORS["tooltip-bg"]}
id={`passing_${cellProps.row.original.id.toString()}`}
data-html
>
@ -238,7 +238,7 @@ const generateTableHeaders = (
<ReactTooltip
place="bottom"
effect="solid"
backgroundColor="#3e4771"
backgroundColor={COLORS["tooltip-bg"]}
id={`failing_${cellProps.row.original.id.toString()}`}
data-html
>

View file

@ -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(() => {

View file

@ -40,7 +40,7 @@ const PoliciesTable = ({
>
<TableContainer
resultsTitle={resultsTitle || "policies"}
columns={generateTableHeaders()}
columnConfigs={generateTableHeaders()}
data={generateDataSet(errorsList)}
isLoading={isLoading}
defaultSortHeader={"name"}

View file

@ -40,7 +40,7 @@ const PoliciesTable = ({
>
<TableContainer
resultsTitle={resultsTitle || "policies"}
columns={generateTableHeaders()}
columnConfigs={generateTableHeaders()}
data={generateDataSet(policyHostsList)}
isLoading={isLoading}
defaultSortHeader={"query_results"}

View file

@ -16,6 +16,7 @@ import Button from "components/buttons/Button";
import Modal from "components/Modal";
import ReactTooltip from "react-tooltip";
import PremiumFeatureIconWithTooltip from "components/PremiumFeatureIconWithTooltip";
import { COLORS } from "styles/var/colors";
export interface ISaveNewPolicyModalProps {
baseClass: string;
@ -186,7 +187,7 @@ const SaveNewPolicyModal = ({
place="bottom"
effect="solid"
id={`${baseClass}__button--modal-save-tooltip`}
backgroundColor="#3e4771"
backgroundColor={COLORS["tooltip-bg"]}
>
Select the platform(s) this
<br />

View file

@ -319,8 +319,12 @@ const ManageQueriesPage = ({
inheritedQueryCount === 1 ? "y" : "ies"
}`}
caretPosition={"before"}
tooltipHtml={
'Queries from the "All teams"<br/>schedule run on this teams hosts.'
tooltipContent={
<>
Queries from the &quot;All teams&quot;
<br />
schedule run on this team&apos;s hosts.
</>
}
onClick={() => {
setShowInheritedQueries(!showInheritedQueries);

View file

@ -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;
}
}
}

View file

@ -293,7 +293,7 @@ const QueriesTable = ({
<TableContainer
disableCount={isInherited}
resultsTitle="queries"
columns={tableHeaders}
columnConfigs={tableHeaders}
data={queriesList}
filters={{ name: isInherited ? "" : searchQuery }}
isLoading={isLoading}

View file

@ -154,7 +154,7 @@ const generateTableHeaders = ({
)}
</>
}
path={PATHS.QUERY(
path={PATHS.QUERY_DETAILS(
cellProps.row.original.id,
cellProps.row.original.team_id ?? currentTeamId
)}

View file

@ -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 = ({
>
<div>
<b>Report clipped.</b> A sample of this query&apos;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."
}
</div>
</InfoBanner>
);

View file

@ -141,7 +141,7 @@ const QueryReport = ({
return (
<div className={`${baseClass}__results-table-container`}>
<TableContainer
columns={columnConfigs}
columnConfigs={columnConfigs}
data={flattenResults(queryReport?.results || [])}
// All empty states are handled in QueryDetailsPage.tsx and returned in lieu of QueryReport.tsx
emptyComponent={() => {

View file

@ -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<TableInstance> & {
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 {

View file

@ -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 =

View file

@ -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.`);
})

View file

@ -194,7 +194,7 @@ const QueryResults = ({
return (
<div className={`${baseClass}__results-table-container`}>
<TableContainer
columns={
columnConfigs={
tableType === "results" ? resultsColumnConfigs : errorColumnConfigs
}
data={tableData || []}

View file

@ -14,7 +14,10 @@ import {
import DefaultColumnFilter from "components/TableContainer/DataTable/DefaultColumnFilter";
import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCell";
import { internallyTruncateText } from "utilities/helpers";
import {
getUniqueColumnNamesFromRows,
internallyTruncateText,
} from "utilities/helpers";
type IHeaderProps = HeaderProps<TableInstance> & {
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,

Some files were not shown because too many files have changed in this diff Show more