mirror of
https://github.com/fleetdm/fleet
synced 2026-05-23 08:58:41 +00:00
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:
commit
0967256cce
129 changed files with 2452 additions and 827 deletions
3
changes/14415-host-query-reports
Normal file
3
changes/14415-host-query-reports
Normal 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
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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=""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -73,10 +73,6 @@
|
|||
|
||||
.Select-arrow-zone {
|
||||
padding-left: 15px;
|
||||
svg {
|
||||
position: relative;
|
||||
top: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.Select-multi-value-wrapper {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ const PackQueriesTable = ({
|
|||
<div className={`${baseClass} body-wrap`}>
|
||||
{scheduledQueries?.length ? (
|
||||
<TableContainer
|
||||
columns={tableHeaders}
|
||||
columnConfigs={tableHeaders}
|
||||
data={tableData}
|
||||
isLoading={isLoadingPackQueries}
|
||||
defaultSortHeader={"name"}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ const OperatingSystems = ({
|
|||
<TableDataError card />
|
||||
) : (
|
||||
<TableContainer
|
||||
columns={tableHeaders}
|
||||
columnConfigs={tableHeaders}
|
||||
data={osInfo?.os_versions || []}
|
||||
isLoading={isFetching}
|
||||
defaultSortHeader={DEFAULT_SORT_HEADER}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ const OSVersionTable = ({
|
|||
return (
|
||||
<div className={baseClass}>
|
||||
<TableContainer
|
||||
columns={columns}
|
||||
columnConfigs={columns}
|
||||
data={osVersionData}
|
||||
isLoading={isLoading}
|
||||
resultsTitle=""
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -259,7 +259,7 @@ const SoftwareTitles = ({
|
|||
return (
|
||||
<div className={baseClass}>
|
||||
<TableContainer
|
||||
columns={softwareTableHeaders}
|
||||
columnConfigs={softwareTableHeaders}
|
||||
data={softwareData?.software_titles || []}
|
||||
isLoading={isSoftwareLoading}
|
||||
resultsTitle={"items"}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -273,7 +273,7 @@ const SoftwareVersions = ({
|
|||
<div className={baseClass}>
|
||||
<div className={baseClass}>
|
||||
<TableContainer
|
||||
columns={softwareTableHeaders}
|
||||
columnConfigs={softwareTableHeaders}
|
||||
data={softwareVersionsData?.software || []}
|
||||
isLoading={isSoftwareVersionsLoading}
|
||||
resultsTitle={"items"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>
|
||||
|
|
|
|||
|
|
@ -407,7 +407,7 @@ const Integrations = (): JSX.Element => {
|
|||
<TableDataError />
|
||||
) : (
|
||||
<TableContainer
|
||||
columns={tableHeaders}
|
||||
columnConfigs={tableHeaders}
|
||||
data={tableData}
|
||||
isLoading={isLoadingIntegrations}
|
||||
defaultSortHeader={"name"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -270,7 +270,7 @@ const TeamManagementPage = (): JSX.Element => {
|
|||
<TableDataError />
|
||||
) : (
|
||||
<TableContainer
|
||||
columns={tableHeaders}
|
||||
columnConfigs={tableHeaders}
|
||||
data={tableData}
|
||||
isLoading={isFetchingTeams}
|
||||
defaultSortHeader={"name"}
|
||||
|
|
|
|||
|
|
@ -535,7 +535,7 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => {
|
|||
|
||||
return (
|
||||
<TableContainer
|
||||
columns={tableHeaders}
|
||||
columnConfigs={tableHeaders}
|
||||
data={tableData}
|
||||
isLoading={loadingTableData}
|
||||
defaultSortHeader={"name"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1466,7 +1466,7 @@ const ManageHostsPage = ({
|
|||
return (
|
||||
<TableContainer
|
||||
resultsTitle="hosts"
|
||||
columns={tableColumns}
|
||||
columnConfigs={tableColumns}
|
||||
data={hostsData?.hosts || []}
|
||||
isLoading={isLoadingHosts || isLoadingHostsCount || isLoadingPolicy}
|
||||
manualSortBy
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./HQRTable";
|
||||
160
frontend/pages/hosts/details/HostQueryReport/HostQueryReport.tsx
Normal file
160
frontend/pages/hosts/details/HostQueryReport/HostQueryReport.tsx
Normal 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;
|
||||
24
frontend/pages/hosts/details/HostQueryReport/_styles.scss
Normal file
24
frontend/pages/hosts/details/HostQueryReport/_styles.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
1
frontend/pages/hosts/details/HostQueryReport/index.ts
Normal file
1
frontend/pages/hosts/details/HostQueryReport/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./HostQueryReport";
|
||||
|
|
@ -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
|
||||
>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ const OSSettingsTable = ({ tableData }: IOSSettingsTableProps) => {
|
|||
<TableContainer
|
||||
resultsTitle="settings"
|
||||
defaultSortHeader="name"
|
||||
columns={tableHeaders}
|
||||
columnConfigs={tableHeaders}
|
||||
data={tableData}
|
||||
emptyComponent={"symbol"}
|
||||
isLoading={false}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 can’t 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}
|
||||
|
||||
</p>
|
||||
</div>
|
||||
{renderRefetch()}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ const MunkiIssuesTable = ({
|
|||
{munkiIssues?.length ? (
|
||||
<div className={deviceType || ""}>
|
||||
<TableContainer
|
||||
columns={tableHeaders}
|
||||
columnConfigs={tableHeaders}
|
||||
data={tableMunkiIssues || []}
|
||||
isLoading={isLoading}
|
||||
defaultSortHeader={"name"}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ const Policies = ({
|
|||
</InfoBanner>
|
||||
)}
|
||||
<TableContainer
|
||||
columns={tableHeaders}
|
||||
columnConfigs={tableHeaders}
|
||||
data={generatePolicyDataSet(policies)}
|
||||
isLoading={isLoading}
|
||||
defaultSortHeader={"name"}
|
||||
|
|
|
|||
118
frontend/pages/hosts/details/cards/Queries/HostQueries.tsx
Normal file
118
frontend/pages/hosts/details/cards/Queries/HostQueries.tsx
Normal 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;
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./ReportUpdatedCell";
|
||||
60
frontend/pages/hosts/details/cards/Queries/_styles.scss
Normal file
60
frontend/pages/hosts/details/cards/Queries/_styles.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
frontend/pages/hosts/details/cards/Queries/index.ts
Normal file
1
frontend/pages/hosts/details/cards/Queries/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./HostQueries";
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default } from "./Schedule";
|
||||
|
|
@ -113,7 +113,7 @@ const Scripts = ({
|
|||
emptyComponent={() => <></>}
|
||||
showMarkAllPages={false}
|
||||
isAllPagesSelected={false}
|
||||
columns={scriptColumnConfigs}
|
||||
columnConfigs={scriptColumnConfigs}
|
||||
data={data}
|
||||
isLoading={isLoadingScriptData}
|
||||
onQueryChange={onQueryChange}
|
||||
|
|
|
|||
|
|
@ -221,7 +221,7 @@ const SoftwareTable = ({
|
|||
<div className={deviceType || ""}>
|
||||
<TableContainer
|
||||
resultsTitle="software items"
|
||||
columns={tableHeaders}
|
||||
columnConfigs={tableHeaders}
|
||||
data={tableSoftware || []}
|
||||
filters={{
|
||||
global: searchQuery,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ const PacksTable = ({
|
|||
<div className={`${baseClass}`}>
|
||||
<TableContainer
|
||||
resultsTitle={"packs"}
|
||||
columns={tableHeaders}
|
||||
columnConfigs={tableHeaders}
|
||||
data={generateDataSet(filteredPacks)}
|
||||
isLoading={isLoading}
|
||||
defaultSortHeader={"pack"}
|
||||
|
|
|
|||
|
|
@ -767,9 +767,13 @@ const ManagePolicyPage = ({
|
|||
globalPoliciesCount
|
||||
)}
|
||||
caretPosition={"before"}
|
||||
tooltipHtml={`"All teams" policies are checked ${(
|
||||
<br />
|
||||
)} for this team's hosts.`}
|
||||
tooltipContent={
|
||||
<>
|
||||
"All teams" policies are checked
|
||||
<br />
|
||||
for this team's hosts.
|
||||
</>
|
||||
}
|
||||
onClick={toggleShowInheritedPolicies}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ const PoliciesTable = ({
|
|||
>
|
||||
<TableContainer
|
||||
resultsTitle={resultsTitle || "policies"}
|
||||
columns={generateTableHeaders()}
|
||||
columnConfigs={generateTableHeaders()}
|
||||
data={generateDataSet(errorsList)}
|
||||
isLoading={isLoading}
|
||||
defaultSortHeader={"name"}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ const PoliciesTable = ({
|
|||
>
|
||||
<TableContainer
|
||||
resultsTitle={resultsTitle || "policies"}
|
||||
columns={generateTableHeaders()}
|
||||
columnConfigs={generateTableHeaders()}
|
||||
data={generateDataSet(policyHostsList)}
|
||||
isLoading={isLoading}
|
||||
defaultSortHeader={"query_results"}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -319,8 +319,12 @@ const ManageQueriesPage = ({
|
|||
inheritedQueryCount === 1 ? "y" : "ies"
|
||||
}`}
|
||||
caretPosition={"before"}
|
||||
tooltipHtml={
|
||||
'Queries from the "All teams"<br/>schedule run on this team’s hosts.'
|
||||
tooltipContent={
|
||||
<>
|
||||
Queries from the "All teams"
|
||||
<br />
|
||||
schedule run on this team's hosts.
|
||||
</>
|
||||
}
|
||||
onClick={() => {
|
||||
setShowInheritedQueries(!showInheritedQueries);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -293,7 +293,7 @@ const QueriesTable = ({
|
|||
<TableContainer
|
||||
disableCount={isInherited}
|
||||
resultsTitle="queries"
|
||||
columns={tableHeaders}
|
||||
columnConfigs={tableHeaders}
|
||||
data={queriesList}
|
||||
filters={{ name: isInherited ? "" : searchQuery }}
|
||||
isLoading={isLoading}
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ const generateTableHeaders = ({
|
|||
)}
|
||||
</>
|
||||
}
|
||||
path={PATHS.QUERY(
|
||||
path={PATHS.QUERY_DETAILS(
|
||||
cellProps.row.original.id,
|
||||
cellProps.row.original.team_id ?? currentTeamId
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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.`);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ const QueryResults = ({
|
|||
return (
|
||||
<div className={`${baseClass}__results-table-container`}>
|
||||
<TableContainer
|
||||
columns={
|
||||
columnConfigs={
|
||||
tableType === "results" ? resultsColumnConfigs : errorColumnConfigs
|
||||
}
|
||||
data={tableData || []}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue