UI – 14415 frontend - host details (#15437)
## Addresses the first major part of #15011 (item 2) – Host Details > Queries tab <img width="1274" alt="Screenshot 2023-12-04 at 1 09 31 PM" src="https://github.com/fleetdm/fleet/assets/61553566/47075ebb-eb98-48f5-82ab-af4022932376"> <img width="678" alt="Screenshot 2023-12-04 at 1 09 57 PM" src="https://github.com/fleetdm/fleet/assets/61553566/db48ca6d-e73b-4a90-b782-f9ee265927f1"> - [x] Added/updated tests - [x] Manual QA for all new/changed functionality --------- Co-authored-by: Jacob Shandling <jacob@fleetdm.com>
This commit is contained in:
co-authored by
Jacob Shandling
parent
5677d734de
commit
bb56e288e5
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -62,7 +62,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";
|
||||
@@ -238,6 +238,96 @@ const HostDetailsPage = ({
|
||||
mdm?.enrollment_status !== null && refetchMdm();
|
||||
};
|
||||
|
||||
// TODO - remove dummy schedule
|
||||
const dummySchedule: IQueryStats[] = [
|
||||
{
|
||||
scheduled_query_name: "cached query 1 - Never reported",
|
||||
query_name: "query 1",
|
||||
description: "should render 'Never' ",
|
||||
discard_data: false,
|
||||
last_fetched: null,
|
||||
automations_enabled: false,
|
||||
interval: 1000,
|
||||
scheduled_query_id: 1,
|
||||
|
||||
pack_id: 1,
|
||||
pack_name: "Team: 💻 Workstations",
|
||||
average_memory: 435814,
|
||||
denylisted: false,
|
||||
executions: 5,
|
||||
last_executed: "2023-11-29T15:20:02Z",
|
||||
output_size: 1204,
|
||||
system_time: 9,
|
||||
user_time: 3,
|
||||
wall_time: 0,
|
||||
},
|
||||
{
|
||||
scheduled_query_name: "cached query 2 - stored results",
|
||||
description: "should render with row clickable to its report",
|
||||
discard_data: false,
|
||||
query_name: "query 2",
|
||||
last_fetched: "2023-11-29T15:20:02Z",
|
||||
automations_enabled: false,
|
||||
interval: 1000,
|
||||
scheduled_query_id: 2,
|
||||
|
||||
pack_id: 1,
|
||||
pack_name: "Team: 💻 Workstations",
|
||||
average_memory: 435814,
|
||||
denylisted: false,
|
||||
executions: 5,
|
||||
last_executed: "2023-11-29T15:20:02Z",
|
||||
output_size: 1204,
|
||||
system_time: 9,
|
||||
user_time: 3,
|
||||
wall_time: 0,
|
||||
},
|
||||
{
|
||||
scheduled_query_name:
|
||||
"cached query 3 - sending results to a log destination, not storing in Fleet",
|
||||
description: "should render '---', not link to report",
|
||||
interval: 1000,
|
||||
discard_data: true,
|
||||
automations_enabled: true,
|
||||
query_name: "query 3",
|
||||
last_fetched: null,
|
||||
scheduled_query_id: 3,
|
||||
|
||||
pack_id: 1,
|
||||
pack_name: "Team: 💻 Workstations",
|
||||
average_memory: 435814,
|
||||
denylisted: false,
|
||||
executions: 5,
|
||||
last_executed: "2023-11-29T15:20:02Z",
|
||||
output_size: 1204,
|
||||
system_time: 9,
|
||||
user_time: 3,
|
||||
wall_time: 0,
|
||||
},
|
||||
{
|
||||
scheduled_query_name:
|
||||
"cached query 4 - stored results, but no current interval",
|
||||
description: "should render with row clickable to its report",
|
||||
discard_data: false,
|
||||
query_name: "query 4",
|
||||
last_fetched: "2023-11-29T15:20:02Z",
|
||||
automations_enabled: false,
|
||||
interval: 0,
|
||||
scheduled_query_id: 4,
|
||||
|
||||
pack_id: 1,
|
||||
pack_name: "Team: 💻 Workstations",
|
||||
average_memory: 435814,
|
||||
denylisted: false,
|
||||
executions: 5,
|
||||
last_executed: "2023-11-29T15:20:02Z",
|
||||
output_size: 1204,
|
||||
system_time: 9,
|
||||
user_time: 3,
|
||||
wall_time: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const {
|
||||
isLoading: isLoadingHost,
|
||||
data: host,
|
||||
@@ -300,6 +390,8 @@ const HostDetailsPage = ({
|
||||
}
|
||||
setHostSoftware(returnedHost.software || []);
|
||||
setUsersState(returnedHost.users || []);
|
||||
// TODO – remove dummy data
|
||||
setSchedule(dummySchedule);
|
||||
if (returnedHost.pack_stats) {
|
||||
const packStatsByType = returnedHost.pack_stats.reduce(
|
||||
(
|
||||
@@ -318,7 +410,8 @@ const HostDetailsPage = ({
|
||||
},
|
||||
{ packs: [], schedule: [] }
|
||||
);
|
||||
setSchedule(packStatsByType.schedule);
|
||||
// TODO - restore real data
|
||||
// setSchedule(packStatsByType.schedule);
|
||||
setPacksState(packStatsByType.packs);
|
||||
}
|
||||
},
|
||||
@@ -583,7 +676,7 @@ const HostDetailsPage = ({
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoadingHost) {
|
||||
if (!host || isLoadingHost) {
|
||||
return <Spinner />;
|
||||
}
|
||||
const failingPoliciesCount = host?.issues.failing_policies_count || 0;
|
||||
@@ -605,9 +698,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: (
|
||||
@@ -768,10 +861,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} />
|
||||
|
||||
@@ -292,4 +292,11 @@
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-table {
|
||||
&__container {
|
||||
margin: 0 0 $pad-xxlarge 0;
|
||||
min-height: initial;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,10 +340,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>
|
||||
|
||||
@@ -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
|
||||
columns={columnConfigs}
|
||||
data={tableData}
|
||||
onQueryChange={() => null}
|
||||
resultsTitle="queries"
|
||||
defaultSortHeader="scheduled_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 };
|
||||
+72
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -11,7 +11,6 @@ import { useQuery } from "react-query";
|
||||
import { InjectedRouter } from "react-router/lib/Router";
|
||||
import { RouteProps } from "react-router/lib/Route";
|
||||
import { isEmpty, isEqual } from "lodash";
|
||||
// import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
import { AppContext } from "context/app";
|
||||
import { NotificationContext } from "context/notification";
|
||||
@@ -48,7 +47,6 @@ import TableDataError from "components/DataError";
|
||||
import Dropdown from "components/forms/fields/Dropdown";
|
||||
import LastUpdatedText from "components/LastUpdatedText";
|
||||
import MainContent from "components/MainContent";
|
||||
import Spinner from "components/Spinner";
|
||||
import TableContainer from "components/TableContainer";
|
||||
import { ITableQueryData } from "components/TableContainer/TableContainer";
|
||||
import TeamsDropdown from "components/TeamsDropdown";
|
||||
@@ -91,7 +89,7 @@ interface ISoftwareAutomations {
|
||||
};
|
||||
}
|
||||
|
||||
interface IRowProps extends Row {
|
||||
interface ISoftwareRowProps extends Row {
|
||||
original: {
|
||||
id?: number;
|
||||
};
|
||||
@@ -593,7 +591,7 @@ const ManageSoftwarePage = ({
|
||||
),
|
||||
[isPremiumTier, isSandboxMode, router, currentTeamId]
|
||||
);
|
||||
const handleRowSelect = (row: IRowProps) => {
|
||||
const onSelectSingleRow = (row: ISoftwareRowProps) => {
|
||||
const hostsBySoftwareParams = {
|
||||
software_id: row.original.id,
|
||||
team_id: currentTeamId,
|
||||
@@ -631,7 +629,7 @@ const ManageSoftwarePage = ({
|
||||
!globalConfig ||
|
||||
(!softwareConfig && !softwareConfigError)
|
||||
}
|
||||
resultsTitle={"software items"}
|
||||
resultsTitle="software items"
|
||||
emptyComponent={() => (
|
||||
<EmptySoftwareTable
|
||||
isSoftwareDisabled={!isSoftwareEnabled}
|
||||
@@ -650,11 +648,8 @@ const ManageSoftwarePage = ({
|
||||
pageSize={DEFAULT_PAGE_SIZE}
|
||||
showMarkAllPages={false}
|
||||
isAllPagesSelected={false}
|
||||
resetPageIndex={resetPageIndex}
|
||||
disableNextPage={isLastPage}
|
||||
searchable={searchable}
|
||||
inputPlaceHolder="Search by name or vulnerabilities (CVEs)"
|
||||
onQueryChange={onQueryChange}
|
||||
additionalQueries={filterVuln ? "vulnerable" : ""} // additionalQueries serves as a trigger
|
||||
// for the useDeepEffect hook to fire onQueryChange for events happeing outside of
|
||||
// the TableContainer
|
||||
@@ -663,7 +658,7 @@ const ManageSoftwarePage = ({
|
||||
renderCount={renderSoftwareCount}
|
||||
renderFooter={renderTableFooter}
|
||||
disableMultiRowSelect
|
||||
onSelectSingleRow={handleRowSelect}
|
||||
{...{ resetPageIndex, searchable, onQueryChange, onSelectSingleRow }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -709,15 +704,17 @@ const ManageSoftwarePage = ({
|
||||
{showManageAutomationsModal && (
|
||||
<ManageAutomationsModal
|
||||
onCancel={toggleManageAutomationsModal}
|
||||
onCreateWebhookSubmit={onCreateWebhookSubmit}
|
||||
togglePreviewPayloadModal={togglePreviewPayloadModal}
|
||||
togglePreviewTicketModal={togglePreviewTicketModal}
|
||||
showPreviewPayloadModal={showPreviewPayloadModal}
|
||||
showPreviewTicketModal={showPreviewTicketModal}
|
||||
softwareVulnerabilityAutomationEnabled={isAnyVulnAutomationEnabled}
|
||||
softwareVulnerabilityWebhookEnabled={isVulnWebhookEnabled}
|
||||
currentDestinationUrl={vulnWebhookSettings?.destination_url || ""}
|
||||
recentVulnerabilityMaxAge={recentVulnerabilityMaxAge}
|
||||
{...{
|
||||
onCreateWebhookSubmit,
|
||||
togglePreviewPayloadModal,
|
||||
togglePreviewTicketModal,
|
||||
showPreviewPayloadModal,
|
||||
showPreviewTicketModal,
|
||||
recentVulnerabilityMaxAge,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -182,7 +182,9 @@ const routes = (
|
||||
<Route path=":host_id" component={HostDetailsPage}>
|
||||
<Route path="scripts" component={HostDetailsPage} />
|
||||
<Route path="software" component={HostDetailsPage} />
|
||||
<Route path="schedule" component={HostDetailsPage} />
|
||||
<Route path="queries" component={HostDetailsPage} />
|
||||
{/* legacy route */}
|
||||
<Redirect from="schedule" to="queries" />
|
||||
<Route path="policies" component={HostDetailsPage} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@@ -92,12 +92,14 @@ export default {
|
||||
HOST_SOFTWARE: (id: number): string => {
|
||||
return `${URL_PREFIX}/hosts/${id}/software`;
|
||||
},
|
||||
HOST_SCHEDULE: (id: number): string => {
|
||||
return `${URL_PREFIX}/hosts/${id}/schedule`;
|
||||
HOST_QUERIES: (id: number): string => {
|
||||
return `${URL_PREFIX}/hosts/${id}/queries`;
|
||||
},
|
||||
HOST_POLICIES: (id: number): string => {
|
||||
return `${URL_PREFIX}/hosts/${id}/policies`;
|
||||
},
|
||||
HOST_QUERY_REPORT: (hostId: number, queryId: number): string =>
|
||||
`${URL_PREFIX}/hosts/${hostId}/queries/${queryId}`,
|
||||
DEVICE_USER_DETAILS: (deviceAuthToken: any): string => {
|
||||
return `${URL_PREFIX}/device/${deviceAuthToken}`;
|
||||
},
|
||||
|
||||
@@ -53,17 +53,7 @@ h1 {
|
||||
}
|
||||
|
||||
a {
|
||||
color: $core-vibrant-blue;
|
||||
font-weight: $bold;
|
||||
font-size: $x-small;
|
||||
text-decoration: none;
|
||||
|
||||
&:focus-visible {
|
||||
outline-color: #d9d9fe;
|
||||
outline-offset: 3px;
|
||||
outline-style: solid;
|
||||
outline-width: 2px;
|
||||
}
|
||||
@include link;
|
||||
}
|
||||
|
||||
.__react_component_tooltip {
|
||||
|
||||
@@ -121,3 +121,32 @@ $max-width: 2560px;
|
||||
font-size: $xx-small;
|
||||
color: $ui-fleet-black-75;
|
||||
}
|
||||
|
||||
@mixin link {
|
||||
color: $core-vibrant-blue;
|
||||
font-weight: $bold;
|
||||
font-size: $x-small;
|
||||
text-decoration: none;
|
||||
&:focus-visible {
|
||||
outline-color: #d9d9fe;
|
||||
outline-offset: 3px;
|
||||
outline-style: solid;
|
||||
outline-width: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin table-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: $pad-small $pad-xxsmall; // larger clickable area
|
||||
gap: $pad-small;
|
||||
white-space: nowrap;
|
||||
@include link;
|
||||
}
|
||||
|
||||
@mixin cell-with-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user