Surface queries in host details (#37646)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #27322 [Figma](https://www.figma.com/design/v7WjL5zQuFIZerWYaSwy8o/-27322-Surface-custom-host-vitals?node-id=5636-4950&t=LuE3Kp09a5sj24Tt-0) ## Testing - [x] Added/updated automated tests - [ ] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [x] QA'd all new/changed functionality manually (WIP) ## Screenshots ### Host details <img width="1481" height="1000" alt="Screenshot 2025-12-26 at 2 14 48 PM" src="https://github.com/user-attachments/assets/3d9f02f9-f3a7-4a06-b3e4-414bb7b56e25" /> - `Queries` tab removed. - Shows `Queries` card. #### Queries Card - Added client-side pagination. - Added `Add query` button (screenshots below are with `Admin` role). <img width="710" height="395" alt="Screenshot 2025-12-26 at 2 15 07 PM" src="https://github.com/user-attachments/assets/b4e58269-d1b2-4c87-abfa-2cdfe47b533e" /> <img width="723" height="301" alt="Screenshot 2025-12-26 at 2 15 00 PM" src="https://github.com/user-attachments/assets/2615d5bf-5d75-4e83-bc69-bc884232bf32" /> - As an `Observer`, `Add query` is not displayed <img width="2240" height="1077" alt="Screenshot 2025-12-26 at 2 27 25 PM" src="https://github.com/user-attachments/assets/426de709-d2ce-4bef-96f1-919ad5bddb13" /> - As a `Maintainer`, `Add query` is displayed <img width="2236" height="1084" alt="Screenshot 2025-12-26 at 2 31 16 PM" src="https://github.com/user-attachments/assets/218b0d18-2536-4336-88c8-41e7d09a5e9e" /> ### New query page If the user navigates from `Host details`, `host_id` search parameter is added to the URL and the back button displays `Back to host details`. <img width="1097" height="506" alt="Screenshot 2025-12-26 at 2 15 32 PM" src="https://github.com/user-attachments/assets/61777c85-22f5-49dc-a3e6-dcd706119c70" /> ### Host Queries (/hosts/:hostId/queries/:queryId) `Performance impact` added above the table. <img width="2029" height="626" alt="Screenshot 2025-12-26 at 2 16 00 PM" src="https://github.com/user-attachments/assets/05c6b1bc-0587-4b0a-8167-142787592c6d" /> <img width="1555" height="482" alt="Screenshot 2025-12-26 at 2 16 05 PM" src="https://github.com/user-attachments/assets/b9035b63-51c3-46c0-a903-c16d54c22986" />
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
- Remove Queries tab from Host Details page.
|
||||
- Surface Queries within the Details tab.
|
||||
+13
-45
@@ -5,6 +5,12 @@ import { uniqueId } from "lodash";
|
||||
import ReactTooltip from "react-tooltip";
|
||||
import { COLORS } from "styles/var/colors";
|
||||
|
||||
import { getPerformanceImpactIndicatorTooltip } from "utilities/helpers";
|
||||
import {
|
||||
isPerformanceImpactIndicator,
|
||||
PerformanceImpactIndicatorValue,
|
||||
} from "interfaces/schedulable_query";
|
||||
|
||||
interface IPerformanceImpactCellValue {
|
||||
indicator: string;
|
||||
id?: number;
|
||||
@@ -40,50 +46,12 @@ const PerformanceImpactCell = ({
|
||||
"Undetermined",
|
||||
].includes(indicator);
|
||||
|
||||
const tooltipText = () => {
|
||||
switch (indicator) {
|
||||
case "Minimal":
|
||||
return (
|
||||
<>
|
||||
Running this query very frequently has little to no <br /> impact on
|
||||
your device's performance.
|
||||
</>
|
||||
);
|
||||
case "Considerable":
|
||||
return (
|
||||
<>
|
||||
Running this query frequently can have a noticeable <br />
|
||||
impact on your device's performance.
|
||||
</>
|
||||
);
|
||||
case "Excessive":
|
||||
return (
|
||||
<>
|
||||
Running this query, even infrequently, can have a <br />
|
||||
significant impact on your device's performance.
|
||||
</>
|
||||
);
|
||||
case "Denylisted":
|
||||
return (
|
||||
<>
|
||||
This query has been <br /> stopped from running <br /> because of
|
||||
excessive <br /> resource consumption.
|
||||
</>
|
||||
);
|
||||
case "Undetermined":
|
||||
return (
|
||||
<>
|
||||
Performance impact will be available when{" "}
|
||||
{isHostSpecific ? "the" : "this"} <br />
|
||||
query runs{isHostSpecific && " on this host"}.
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const tooltipId = uniqueId();
|
||||
|
||||
const indicatorValue = isPerformanceImpactIndicator(indicator)
|
||||
? indicator
|
||||
: PerformanceImpactIndicatorValue.UNDETERMINED;
|
||||
|
||||
return (
|
||||
<span className={`${baseClass}`}>
|
||||
<span
|
||||
@@ -91,7 +59,7 @@ const PerformanceImpactCell = ({
|
||||
data-for={`${customIdPrefix || "pill"}__${id?.toString() || tooltipId}`}
|
||||
data-tip-disable={disableTooltip}
|
||||
>
|
||||
<span className={pillClassName}>{indicator}</span>
|
||||
<span className={pillClassName}>{indicatorValue}</span>
|
||||
</span>
|
||||
<ReactTooltip
|
||||
place="top"
|
||||
@@ -102,10 +70,10 @@ const PerformanceImpactCell = ({
|
||||
>
|
||||
<span
|
||||
className={`tooltip ${generateClassTag(
|
||||
indicator || ""
|
||||
indicatorValue || ""
|
||||
)}__tooltip-text`}
|
||||
>
|
||||
{tooltipText()}
|
||||
{getPerformanceImpactIndicatorTooltip(indicatorValue, isHostSpecific)}
|
||||
</span>
|
||||
</ReactTooltip>
|
||||
</span>
|
||||
|
||||
@@ -4,13 +4,14 @@ import SQLEditor from "components/SQLEditor";
|
||||
import Modal from "components/Modal";
|
||||
import Button from "components/buttons/Button";
|
||||
import PerformanceImpactCell from "components/TableContainer/DataTable/PerformanceImpactCell";
|
||||
import { PerformanceImpactIndicator } from "interfaces/schedulable_query";
|
||||
|
||||
const baseClass = "show-query-modal";
|
||||
|
||||
interface IShowQueryModalProps {
|
||||
onCancel: () => void;
|
||||
query?: string;
|
||||
impact?: string;
|
||||
impact?: PerformanceImpactIndicator;
|
||||
}
|
||||
|
||||
const ShowQueryModal = ({
|
||||
|
||||
@@ -167,6 +167,7 @@ type InitialStateType = {
|
||||
isOnlyObserver?: boolean;
|
||||
isObserverPlus?: boolean;
|
||||
isNoAccess?: boolean;
|
||||
isAnyMaintainerAdminObserverPlus?: boolean;
|
||||
isAndroidEnterpriseDeleted: boolean;
|
||||
isAppleBmExpired: boolean;
|
||||
isApplePnsExpired: boolean;
|
||||
@@ -237,6 +238,7 @@ export const initialState = {
|
||||
isOnlyObserver: undefined,
|
||||
isObserverPlus: undefined,
|
||||
isNoAccess: undefined,
|
||||
isAnyMaintainerAdminObserverPlus: undefined,
|
||||
filteredHostsPath: undefined,
|
||||
filteredSoftwarePath: undefined,
|
||||
filteredQueriesPath: undefined,
|
||||
@@ -522,6 +524,13 @@ const AppProvider = ({ children }: Props): JSX.Element => {
|
||||
isOnlyObserver: state.isOnlyObserver,
|
||||
isObserverPlus: state.isObserverPlus,
|
||||
isNoAccess: state.isNoAccess,
|
||||
isAnyMaintainerAdminObserverPlus:
|
||||
state.isGlobalAdmin ||
|
||||
state.isGlobalMaintainer ||
|
||||
state.isAnyTeamAdmin ||
|
||||
state.isAnyTeamMaintainer ||
|
||||
state.isObserverPlus ||
|
||||
state.isAnyTeamObserverPlus,
|
||||
setAvailableTeams: (
|
||||
user: IUser | null,
|
||||
availableTeams: ITeamSummary[]
|
||||
|
||||
@@ -37,7 +37,7 @@ export interface ISchedulableQuery {
|
||||
}
|
||||
|
||||
export interface IEnhancedQuery extends ISchedulableQuery {
|
||||
performance: string;
|
||||
performance: PerformanceImpactIndicator;
|
||||
targetedPlatforms: QueryablePlatform[];
|
||||
}
|
||||
export interface ISchedulableQueryStats {
|
||||
@@ -48,6 +48,24 @@ export interface ISchedulableQueryStats {
|
||||
total_executions?: number;
|
||||
}
|
||||
|
||||
export const PerformanceImpactIndicatorValue = {
|
||||
MINIMAL: "Minimal",
|
||||
CONSIDERABLE: "Considerable",
|
||||
EXCESSIVE: "Excessive",
|
||||
UNDETERMINED: "Undetermined",
|
||||
DENYLISTED: "Denylisted",
|
||||
} as const;
|
||||
|
||||
export type PerformanceImpactIndicator = typeof PerformanceImpactIndicatorValue[keyof typeof PerformanceImpactIndicatorValue];
|
||||
|
||||
export const isPerformanceImpactIndicator = (
|
||||
value: unknown
|
||||
): value is PerformanceImpactIndicator => {
|
||||
return Object.values(PerformanceImpactIndicatorValue).includes(
|
||||
value as PerformanceImpactIndicator
|
||||
);
|
||||
};
|
||||
|
||||
// legacy
|
||||
export default PropTypes.shape({
|
||||
user_time_p50: PropTypes.number,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
SCRIPT_PACKAGE_SOURCES,
|
||||
} from "interfaces/software";
|
||||
import { ActivityType, IActivityDetails } from "interfaces/activity";
|
||||
import { PerformanceImpactIndicator } from "interfaces/schedulable_query";
|
||||
|
||||
import { getPerformanceImpactDescription } from "utilities/helpers";
|
||||
|
||||
@@ -141,7 +142,7 @@ const ActivityFeed = ({
|
||||
const [typeFilter, setTypeFilter] = useState<string[]>([""]);
|
||||
|
||||
const queryShown = useRef("");
|
||||
const queryImpact = useRef<string | undefined>(undefined);
|
||||
const queryImpact = useRef<PerformanceImpactIndicator | undefined>(undefined);
|
||||
const scriptExecutionId = useRef("");
|
||||
|
||||
const { startDate, endDate } = useMemo(() => generateDateFilter(dateFilter), [
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
HOST_OSQUERY_DATA,
|
||||
DEFAULT_USE_QUERY_OPTIONS,
|
||||
} from "utilities/constants";
|
||||
import { getPathWithQueryParams } from "utilities/url";
|
||||
|
||||
import {
|
||||
isAppleDevice,
|
||||
@@ -107,7 +108,6 @@ import SoftwareLibraryCard from "../cards/HostSoftwareLibrary";
|
||||
import LocalUserAccountsCard from "../cards/LocalUserAccounts";
|
||||
import PoliciesCard from "../cards/Policies";
|
||||
import QueriesCard from "../cards/Queries";
|
||||
import PacksCard from "../cards/Packs";
|
||||
import PolicyDetailsModal from "../cards/Policies/HostPoliciesTable/PolicyDetailsModal";
|
||||
import CertificatesCard from "../cards/Certificates";
|
||||
|
||||
@@ -141,7 +141,7 @@ const baseClass = "host-details";
|
||||
|
||||
const defaultCardClass = `${baseClass}__card`;
|
||||
const fullWidthCardClass = `${baseClass}__card--full-width`;
|
||||
const tripleHeightCardClass = `${baseClass}__card--triple-height`;
|
||||
const doubleHeightCardClass = `${baseClass}__card--double-height`;
|
||||
|
||||
export const REFETCH_HOST_DETAILS_POLLING_INTERVAL = 2000; // 2 seconds
|
||||
const BYOD_SW_INSTALL_LEARN_MORE_LINK =
|
||||
@@ -197,12 +197,12 @@ const HostDetailsPage = ({
|
||||
currentUser,
|
||||
isGlobalAdmin = false,
|
||||
isGlobalMaintainer,
|
||||
isGlobalObserver,
|
||||
isTeamMaintainerOrTeamAdmin,
|
||||
isPremiumTier = false,
|
||||
isOnlyObserver,
|
||||
filteredHostsPath,
|
||||
currentTeam,
|
||||
isAnyMaintainerAdminObserverPlus,
|
||||
} = useContext(AppContext);
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
|
||||
@@ -259,7 +259,6 @@ const HostDetailsPage = ({
|
||||
const [refetchStartTime, setRefetchStartTime] = useState<number | null>(null);
|
||||
const [showRefetchSpinner, setShowRefetchSpinner] = useState(false);
|
||||
const [schedule, setSchedule] = useState<IQueryStats[]>();
|
||||
const [packsState, setPackState] = useState<IPackStats[]>();
|
||||
const [usersState, setUsersState] = useState<{ username: string }[]>([]);
|
||||
const [usersSearchString, setUsersSearchString] = useState("");
|
||||
const [
|
||||
@@ -949,6 +948,15 @@ const HostDetailsPage = ({
|
||||
setSelectedCertificate(certificate);
|
||||
};
|
||||
|
||||
const onClickAddQuery = () => {
|
||||
router.push(
|
||||
getPathWithQueryParams(PATHS.NEW_QUERY, {
|
||||
team_id: currentTeam?.id,
|
||||
host_id: hostIdFromURL,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const renderActionsDropdown = () => {
|
||||
if (!host) {
|
||||
return null;
|
||||
@@ -1028,11 +1036,6 @@ const HostDetailsPage = ({
|
||||
title: "software",
|
||||
pathname: PATHS.HOST_SOFTWARE(hostIdFromURL),
|
||||
},
|
||||
{
|
||||
name: "Queries",
|
||||
title: "queries",
|
||||
pathname: PATHS.HOST_QUERIES(hostIdFromURL),
|
||||
},
|
||||
{
|
||||
name: "Policies",
|
||||
title: "policies",
|
||||
@@ -1086,23 +1089,6 @@ const HostDetailsPage = ({
|
||||
host?.team_id
|
||||
);
|
||||
|
||||
/* Context team id might be different that host's team id
|
||||
Observer plus must be checked against host's team id */
|
||||
const isGlobalOrHostsTeamObserverPlus =
|
||||
currentUser && host?.team_id
|
||||
? permissions.isObserverPlus(currentUser, host.team_id)
|
||||
: false;
|
||||
|
||||
const isHostsTeamObserver =
|
||||
currentUser && host?.team_id
|
||||
? permissions.isTeamObserver(currentUser, host.team_id)
|
||||
: false;
|
||||
|
||||
const canViewPacks =
|
||||
!isGlobalObserver &&
|
||||
!isGlobalOrHostsTeamObserverPlus &&
|
||||
!isHostsTeamObserver;
|
||||
|
||||
const bootstrapPackageData = {
|
||||
status: host?.mdm.macos_setup?.bootstrap_package_status,
|
||||
details: host?.mdm.macos_setup?.details,
|
||||
@@ -1317,11 +1303,39 @@ Observer plus must be checked against host's team id */
|
||||
host.platform
|
||||
)}
|
||||
/>
|
||||
<QueriesCard
|
||||
hostId={host.id}
|
||||
router={router}
|
||||
hostPlatform={host.platform}
|
||||
schedule={schedule}
|
||||
queryReportsDisabled={
|
||||
config?.server_settings?.query_reports_disabled
|
||||
}
|
||||
canAddQuery={isAnyMaintainerAdminObserverPlus}
|
||||
onClickAddQuery={onClickAddQuery}
|
||||
/>
|
||||
<UserCard
|
||||
className={defaultCardClass}
|
||||
endUsers={host.end_users ?? []}
|
||||
canWriteEndUser={
|
||||
isTeamMaintainerOrTeamAdmin ||
|
||||
isGlobalAdmin ||
|
||||
isGlobalMaintainer
|
||||
}
|
||||
onClickUpdateUser={(
|
||||
e:
|
||||
| React.MouseEvent<HTMLButtonElement>
|
||||
| React.KeyboardEvent<HTMLButtonElement>
|
||||
) => {
|
||||
e.preventDefault();
|
||||
setShowUpdateEndUserModal(true);
|
||||
}}
|
||||
/>
|
||||
{showActivityCard && (
|
||||
<ActivityCard
|
||||
className={
|
||||
showAgentOptionsCard
|
||||
? tripleHeightCardClass
|
||||
? doubleHeightCardClass
|
||||
: defaultCardClass
|
||||
}
|
||||
activeTab={activeActivityTab}
|
||||
@@ -1375,23 +1389,6 @@ Observer plus must be checked against host's team id */
|
||||
onCancel={onCancelActivity}
|
||||
/>
|
||||
)}
|
||||
<UserCard
|
||||
className={defaultCardClass}
|
||||
endUsers={host.end_users ?? []}
|
||||
canWriteEndUser={
|
||||
isTeamMaintainerOrTeamAdmin ||
|
||||
isGlobalAdmin ||
|
||||
isGlobalMaintainer
|
||||
}
|
||||
onClickUpdateUser={(
|
||||
e:
|
||||
| React.MouseEvent<HTMLButtonElement>
|
||||
| React.KeyboardEvent<HTMLButtonElement>
|
||||
) => {
|
||||
e.preventDefault();
|
||||
setShowUpdateEndUserModal(true);
|
||||
}}
|
||||
/>
|
||||
{showAgentOptionsCard && (
|
||||
<AgentOptionsCard
|
||||
className={defaultCardClass}
|
||||
@@ -1448,23 +1445,6 @@ Observer plus must be checked against host's team id */
|
||||
</Tabs>
|
||||
</TabNav>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<QueriesCard
|
||||
hostId={host.id}
|
||||
router={router}
|
||||
hostPlatform={host.platform}
|
||||
schedule={schedule}
|
||||
queryReportsDisabled={
|
||||
config?.server_settings?.query_reports_disabled
|
||||
}
|
||||
/>
|
||||
{canViewPacks && (
|
||||
<PacksCard
|
||||
packsState={packsState}
|
||||
isLoading={isLoadingHost}
|
||||
/>
|
||||
)}
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<PoliciesCard
|
||||
policies={host?.policies || []}
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
grid-column: span 2; // card will fill the whole row
|
||||
}
|
||||
|
||||
&--triple-height {
|
||||
grid-row: span 3; // card will be 1 column x 3 rows
|
||||
&--double-height {
|
||||
grid-row: span 2; // card will be 1 column x 2 rows
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ describe("HQRTable component", () => {
|
||||
it("Renders results normally when they are present", () => {
|
||||
const testData: IHQRTable[] = [
|
||||
{
|
||||
queryId: 1,
|
||||
queryName: "testQuery0",
|
||||
queryDescription: "testDescription0",
|
||||
hostName: "testHost0",
|
||||
@@ -50,6 +51,7 @@ describe("HQRTable component", () => {
|
||||
it("Renders the 'collecting results' empty state when results have never been collected.", () => {
|
||||
const testData: IHQRTable[] = [
|
||||
{
|
||||
queryId: 1,
|
||||
queryName: "testQuery0",
|
||||
queryDescription: "testDescription0",
|
||||
hostName: "testHost0",
|
||||
@@ -71,6 +73,7 @@ describe("HQRTable component", () => {
|
||||
it("Renders the 'report clipped' empty state when reporting for this query has been paused and there are no existing results.", () => {
|
||||
const testData: IHQRTable[] = [
|
||||
{
|
||||
queryId: 1,
|
||||
queryName: "testQuery0",
|
||||
queryDescription: "testDescription0",
|
||||
hostName: "testHost0",
|
||||
@@ -92,6 +95,7 @@ describe("HQRTable component", () => {
|
||||
it("Renders the 'nothing to report' empty state when the query has run and there are no results.", () => {
|
||||
const testData: IHQRTable[] = [
|
||||
{
|
||||
queryId: 1,
|
||||
queryName: "testQuery0",
|
||||
queryDescription: "testDescription0",
|
||||
hostName: "testHost0",
|
||||
|
||||
@@ -12,13 +12,57 @@ import {
|
||||
import FileSaver from "file-saver";
|
||||
import Spinner from "components/Spinner";
|
||||
import { HumanTimeDiffWithFleetLaunchCutoff } from "components/HumanTimeDiffWithDateTip";
|
||||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
import {
|
||||
getPerformanceImpactDescription,
|
||||
getPerformanceImpactIndicatorTooltip,
|
||||
} from "utilities/helpers";
|
||||
import { ISchedulableQueryStats } from "interfaces/schedulable_query";
|
||||
import generateColumnConfigs from "./HQRTableConfig";
|
||||
|
||||
const baseClass = "hqr-table";
|
||||
const DEFAULT_CSV_TITLE = "Host-Specific Query Report";
|
||||
|
||||
type PerformanceImpactProps = {
|
||||
queryStats?: ISchedulableQueryStats;
|
||||
queryId: number;
|
||||
};
|
||||
|
||||
const PerformanceImpact = ({ queryStats, queryId }: PerformanceImpactProps) => {
|
||||
const { total_executions = 0, user_time_p50 = 0, system_time_p50 = 0 } =
|
||||
queryStats || {};
|
||||
|
||||
const scheduledQueryPerformance = {
|
||||
user_time_p50:
|
||||
total_executions > 0 ? Number(user_time_p50) / total_executions : 0,
|
||||
system_time_p50:
|
||||
total_executions > 0 ? Number(system_time_p50) / total_executions : 0,
|
||||
total_executions,
|
||||
};
|
||||
|
||||
const performanceImpact = {
|
||||
indicator: getPerformanceImpactDescription(scheduledQueryPerformance),
|
||||
id: queryId,
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipWrapper
|
||||
tipContent={getPerformanceImpactIndicatorTooltip(
|
||||
performanceImpact.indicator
|
||||
)}
|
||||
>
|
||||
<span className="performance-impact">
|
||||
<strong>Performance impact</strong>: {performanceImpact.indicator}
|
||||
</span>
|
||||
</TooltipWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export interface IHQRTable {
|
||||
queryId: number;
|
||||
queryName?: string;
|
||||
queryDescription?: string;
|
||||
queryStats?: ISchedulableQueryStats;
|
||||
hostName?: string;
|
||||
rows: Record<string, string>[];
|
||||
reportClipped?: boolean;
|
||||
@@ -27,11 +71,11 @@ export interface IHQRTable {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_CSV_TITLE = "Host-Specific Query Report";
|
||||
|
||||
const HQRTable = ({
|
||||
queryId,
|
||||
queryName,
|
||||
queryDescription,
|
||||
queryStats,
|
||||
hostName,
|
||||
rows,
|
||||
reportClipped,
|
||||
@@ -85,8 +129,6 @@ const HQRTable = ({
|
||||
}, [onShowQuery, filteredResults, queryName, hostName, columnConfigs]);
|
||||
|
||||
const renderEmptyState = useCallback(() => {
|
||||
// rows.length === 0
|
||||
|
||||
if (reportClipped) {
|
||||
return (
|
||||
<EmptyTable
|
||||
@@ -134,11 +176,14 @@ const HQRTable = ({
|
||||
const renderTableInfo = useCallback(
|
||||
() => (
|
||||
<div className={`${baseClass}__query-info`}>
|
||||
<h2>{queryName}</h2>
|
||||
<h3>{queryDescription}</h3>
|
||||
<div>
|
||||
<h2>{queryName}</h2>
|
||||
<h3>{queryDescription}</h3>
|
||||
</div>
|
||||
<PerformanceImpact queryStats={queryStats} queryId={queryId} />
|
||||
</div>
|
||||
),
|
||||
[queryDescription, queryName]
|
||||
[queryDescription, queryName, queryStats, queryId]
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
.hqr-table {
|
||||
@include vertical-card-layout;
|
||||
|
||||
.performance-impact {
|
||||
font-size: $x-small;
|
||||
}
|
||||
|
||||
.last-fetched {
|
||||
font-weight: initial;
|
||||
@include grey-text;
|
||||
@@ -13,7 +17,8 @@
|
||||
|
||||
&__query-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: $pad-xsmall;
|
||||
|
||||
h2 {
|
||||
@@ -26,6 +31,11 @@
|
||||
font-weight: $regular;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: $break-sm) {
|
||||
flex-direction: column;
|
||||
gap: $pad-medium;
|
||||
}
|
||||
}
|
||||
|
||||
.data-table {
|
||||
|
||||
@@ -92,6 +92,7 @@ const HostQueryReport = ({
|
||||
description: queryDescription,
|
||||
query: querySQL,
|
||||
discard_data: queryDiscardData,
|
||||
stats,
|
||||
} = queryResponse || {};
|
||||
|
||||
// previous reroute can be done before API call, not this one, hence 2
|
||||
@@ -118,7 +119,7 @@ const HostQueryReport = ({
|
||||
<div className={`${baseClass}__header__row1`}>
|
||||
<BackButton
|
||||
text="Back to host details"
|
||||
path={PATHS.HOST_QUERIES(hostId)}
|
||||
path={PATHS.HOST_DETAILS_PAGE(hostId)}
|
||||
/>
|
||||
</div>
|
||||
<div className={`${baseClass}__header__row2`}>
|
||||
@@ -131,7 +132,7 @@ const HostQueryReport = ({
|
||||
iconStroke
|
||||
>
|
||||
<>
|
||||
View full query report
|
||||
View data for all hosts
|
||||
<Icon name="chevron-right" color="core-fleet-green" />
|
||||
</>
|
||||
</Button>
|
||||
@@ -148,8 +149,10 @@ const HostQueryReport = ({
|
||||
<>
|
||||
<HQRHeader />
|
||||
<HQRTable
|
||||
queryId={queryId}
|
||||
queryName={queryName}
|
||||
queryDescription={queryDescription}
|
||||
queryStats={stats}
|
||||
hostName={hostName}
|
||||
rows={rows}
|
||||
reportClipped={reportClipped}
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import React from "react";
|
||||
import { uniqueId } from "lodash";
|
||||
|
||||
import { IQueryStats } from "interfaces/query_stats";
|
||||
import {
|
||||
humanQueryLastRun,
|
||||
getPerformanceImpactDescription,
|
||||
secondsToHms,
|
||||
} from "utilities/helpers";
|
||||
|
||||
import TextCell from "components/TableContainer/DataTable/TextCell";
|
||||
import PerformanceImpactCell from "components/TableContainer/DataTable/PerformanceImpactCell";
|
||||
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 IPerformanceImpactCell 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: IPerformanceImpactCell) => JSX.Element);
|
||||
disableHidden?: boolean;
|
||||
disableSortBy?: boolean;
|
||||
}
|
||||
|
||||
interface IPackTable extends Partial<IQueryStats> {
|
||||
frequency: string;
|
||||
last_run: 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 generatePackTableHeaders = (): 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={
|
||||
<>
|
||||
The last time the query ran
|
||||
<br />
|
||||
since the last time osquery <br />
|
||||
started on this host.
|
||||
</>
|
||||
}
|
||||
>
|
||||
Last run
|
||||
</TooltipWrapper>
|
||||
);
|
||||
},
|
||||
disableSortBy: true,
|
||||
accessor: "last_run",
|
||||
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: IPerformanceImpactCell) => (
|
||||
<PerformanceImpactCell
|
||||
value={cellProps.cell.value}
|
||||
customIdPrefix="query-perf-pill"
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const enhancePackData = (query_stats: IQueryStats[]): IPackTable[] => {
|
||||
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,
|
||||
last_executed: query.last_executed,
|
||||
frequency: secondsToHms(query.interval),
|
||||
last_run: humanQueryLastRun(query.last_executed),
|
||||
performance: {
|
||||
indicator: getPerformanceImpactDescription(scheduledQueryPerformance),
|
||||
id: query.scheduled_query_id || parseInt(uniqueId(), 10),
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const generatePackDataSet = (query_stats: IQueryStats[]): IPackTable[] => {
|
||||
if (!query_stats) {
|
||||
return query_stats;
|
||||
}
|
||||
|
||||
return [...enhancePackData(query_stats)];
|
||||
};
|
||||
|
||||
export { generatePackTableHeaders, generatePackDataSet };
|
||||
@@ -1,84 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
import { IPackStats } from "interfaces/host";
|
||||
import TableContainer from "components/TableContainer";
|
||||
import Card from "components/Card";
|
||||
import CardHeader from "components/CardHeader";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionItemHeading,
|
||||
AccordionItemButton,
|
||||
AccordionItemPanel,
|
||||
} from "react-accessible-accordion";
|
||||
|
||||
import {
|
||||
generatePackTableHeaders,
|
||||
generatePackDataSet,
|
||||
} from "./PackTable/PackTableConfig";
|
||||
|
||||
const baseClass = "schedule-card";
|
||||
|
||||
interface IPacksProps {
|
||||
packsState?: IPackStats[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const Packs = ({ packsState, isLoading }: IPacksProps): JSX.Element => {
|
||||
const packs = packsState;
|
||||
const wrapperClassName = `${baseClass}__pack-table`;
|
||||
const tableHeaders = generatePackTableHeaders();
|
||||
|
||||
let packsAccordion;
|
||||
if (packs) {
|
||||
packsAccordion = packs.map((pack) => {
|
||||
return (
|
||||
<AccordionItem key={pack.pack_id}>
|
||||
<AccordionItemHeading>
|
||||
<AccordionItemButton>{pack.pack_name}</AccordionItemButton>
|
||||
</AccordionItemHeading>
|
||||
<AccordionItemPanel>
|
||||
{pack.query_stats.length === 0 ? (
|
||||
<div>There are no schedule queries for this pack.</div>
|
||||
) : (
|
||||
<>
|
||||
{!!pack.query_stats.length && (
|
||||
<div className={`${wrapperClassName}`}>
|
||||
<TableContainer
|
||||
columnConfigs={tableHeaders}
|
||||
data={generatePackDataSet(pack.query_stats)}
|
||||
isLoading={isLoading}
|
||||
onQueryChange={() => null}
|
||||
resultsTitle="queries"
|
||||
defaultSortHeader="scheduled_query_name"
|
||||
defaultSortDirection="asc"
|
||||
showMarkAllPages={false}
|
||||
isAllPagesSelected={false}
|
||||
emptyComponent={() => <></>}
|
||||
disablePagination
|
||||
disableCount
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</AccordionItemPanel>
|
||||
</AccordionItem>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return !packs || !packs.length ? (
|
||||
<></>
|
||||
) : (
|
||||
<Card className={baseClass} borderRadiusSize="xxlarge" paddingSize="xlarge">
|
||||
<CardHeader header="Packs" />
|
||||
<Accordion allowMultipleExpanded allowZeroExpanded>
|
||||
{packsAccordion}
|
||||
</Accordion>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default Packs;
|
||||
@@ -1,108 +0,0 @@
|
||||
.card--packs {
|
||||
.table-container__header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.data-table-block {
|
||||
.data-table__table {
|
||||
thead {
|
||||
.query_name__header {
|
||||
width: $col-lg;
|
||||
}
|
||||
.frequency__header {
|
||||
width: $col-md;
|
||||
}
|
||||
.last_run__header {
|
||||
display: none;
|
||||
width: 0;
|
||||
}
|
||||
@media (min-width: $break-md) {
|
||||
.last_run__header {
|
||||
display: table-cell;
|
||||
}
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
.query_name__cell {
|
||||
width: $col-lg;
|
||||
}
|
||||
.frequency__cell {
|
||||
width: $col-md;
|
||||
}
|
||||
.last_run__cell {
|
||||
display: none;
|
||||
width: 0;
|
||||
}
|
||||
@media (min-width: $break-md) {
|
||||
.last_run__cell {
|
||||
display: table-cell;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.accordion {
|
||||
border-radius: 2px;
|
||||
|
||||
.accordion__item + .accordion__item {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&__button {
|
||||
background-color: #fff;
|
||||
color: $core-fleet-black;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: $x-small;
|
||||
font-weight: $bold;
|
||||
border: none;
|
||||
padding: 17px 12px;
|
||||
|
||||
&:hover {
|
||||
background-color: $ui-fleet-black-10;
|
||||
}
|
||||
|
||||
&:after {
|
||||
display: block;
|
||||
content: url("../assets/images/icon-chevron-purple-9x6@2x.png");
|
||||
text-align: center;
|
||||
top: 50%;
|
||||
float: right;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
transform: scale(0.5) translate(40%, -40%);
|
||||
}
|
||||
|
||||
&[aria-expanded="true"]::after,
|
||||
&[aria-selected="true"]::after {
|
||||
background-color: $core-vibrant-blue;
|
||||
content: url("../assets/images/icon-accordion-collapse-16x16@2x.png");
|
||||
}
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__panel {
|
||||
padding: 0;
|
||||
animation: fadein 0.35s ease-in;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- */
|
||||
/* ---------------- Animation part ------------------ */
|
||||
/* -------------------------------------------------- */
|
||||
|
||||
@keyframes fadein {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from "./Packs";
|
||||
@@ -1,12 +1,14 @@
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
|
||||
import { isAndroid } from "interfaces/platform";
|
||||
import { isAndroid, HostPlatform } from "interfaces/platform";
|
||||
import { IQueryStats } from "interfaces/query_stats";
|
||||
import { SUPPORT_LINK } from "utilities/constants";
|
||||
import TableContainer from "components/TableContainer";
|
||||
import EmptyTable from "components/EmptyTable";
|
||||
import Card from "components/Card";
|
||||
import Button from "components/buttons/Button";
|
||||
import CustomLink from "components/CustomLink";
|
||||
import CardHeader from "components/CardHeader";
|
||||
import Icon from "components/Icon";
|
||||
import PATHS from "router/paths";
|
||||
import { InjectedRouter } from "react-router";
|
||||
import { Row } from "react-table";
|
||||
@@ -17,13 +19,16 @@ import {
|
||||
} from "./HostQueriesTableConfig";
|
||||
|
||||
const baseClass = "host-queries-card";
|
||||
const PAGE_SIZE = 4;
|
||||
|
||||
interface IHostQueriesProps {
|
||||
hostId: number;
|
||||
schedule?: IQueryStats[];
|
||||
hostPlatform: string;
|
||||
hostPlatform: HostPlatform;
|
||||
queryReportsDisabled?: boolean;
|
||||
router: InjectedRouter;
|
||||
canAddQuery?: boolean;
|
||||
onClickAddQuery: () => void;
|
||||
}
|
||||
|
||||
interface IHostQueriesRowProps extends Row {
|
||||
@@ -34,74 +39,49 @@ interface IHostQueriesRowProps extends Row {
|
||||
};
|
||||
}
|
||||
|
||||
type EmptyHostQueriesProps = {
|
||||
hostPlatform: HostPlatform;
|
||||
};
|
||||
|
||||
const EmptyHostQueries = ({ hostPlatform }: EmptyHostQueriesProps) => {
|
||||
const platformActions: Record<string, string> = {
|
||||
chrome: "collecting data from your Chromebooks",
|
||||
ios: "querying iPhones",
|
||||
ipados: "querying iPads",
|
||||
android: "querying Android hosts",
|
||||
};
|
||||
|
||||
const action = platformActions[hostPlatform];
|
||||
|
||||
if (action) {
|
||||
return (
|
||||
<div>
|
||||
<p className="empty-header">Queries not supported for this host</p>
|
||||
<p>
|
||||
Interested in {action}?{" "}
|
||||
<CustomLink url={SUPPORT_LINK} text="Let us know" newTab />
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="empty-header">No queries</p>
|
||||
<p>Add a query to view custom vitals.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HostQueries = ({
|
||||
hostId,
|
||||
schedule,
|
||||
hostPlatform,
|
||||
queryReportsDisabled,
|
||||
router,
|
||||
canAddQuery,
|
||||
onClickAddQuery,
|
||||
}: IHostQueriesProps): JSX.Element => {
|
||||
const renderEmptyQueriesTab = () => {
|
||||
if (hostPlatform === "chrome") {
|
||||
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
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (hostPlatform === "ios" || hostPlatform === "ipados") {
|
||||
return (
|
||||
<EmptyTable
|
||||
header="Queries are not supported for this host"
|
||||
info={
|
||||
<>
|
||||
Interested in querying{" "}
|
||||
{hostPlatform === "ios" ? "iPhones" : "iPads"}?{" "}
|
||||
<CustomLink url={SUPPORT_LINK} text="Let us know" newTab />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAndroid(hostPlatform)) {
|
||||
return (
|
||||
<EmptyTable
|
||||
header="Queries are not supported for this host"
|
||||
info={
|
||||
<>
|
||||
Interested in querying Android hosts?{" "}
|
||||
<CustomLink url={SUPPORT_LINK} 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;
|
||||
@@ -127,38 +107,48 @@ const HostQueries = ({
|
||||
!schedule.length ||
|
||||
hostPlatform === "chrome" ||
|
||||
hostPlatform === "ios" ||
|
||||
hostPlatform === "ipados"
|
||||
hostPlatform === "ipados" ||
|
||||
isAndroid(hostPlatform)
|
||||
) {
|
||||
return renderEmptyQueriesTab();
|
||||
return <EmptyHostQueries hostPlatform={hostPlatform} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TableContainer
|
||||
columnConfigs={columnConfigs}
|
||||
data={tableData}
|
||||
onQueryChange={() => null}
|
||||
resultsTitle="queries"
|
||||
defaultSortHeader="query_name"
|
||||
defaultSortDirection="asc"
|
||||
showMarkAllPages={false}
|
||||
isAllPagesSelected={false}
|
||||
emptyComponent={() => <></>}
|
||||
disablePagination
|
||||
disableCount
|
||||
disableMultiRowSelect={!queryReportsDisabled} // Removes hover/click state if reports are disabled
|
||||
isLoading={false} // loading state handled at parent level
|
||||
onSelectSingleRow={onSelectSingleRow}
|
||||
/>
|
||||
</div>
|
||||
<TableContainer
|
||||
columnConfigs={columnConfigs}
|
||||
data={tableData}
|
||||
onQueryChange={() => null}
|
||||
resultsTitle="queries"
|
||||
defaultSortHeader="query_name"
|
||||
defaultSortDirection="asc"
|
||||
showMarkAllPages={false}
|
||||
isAllPagesSelected={false}
|
||||
emptyComponent={() => <></>}
|
||||
disablePagination={tableData.length <= PAGE_SIZE}
|
||||
pageSize={PAGE_SIZE}
|
||||
isClientSidePagination
|
||||
disableCount
|
||||
disableMultiRowSelect={!queryReportsDisabled} // Removes hover/click state if reports are disabled
|
||||
isLoading={false} // loading state handled at parent level
|
||||
onSelectSingleRow={onSelectSingleRow}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<CardHeader header="Queries" />
|
||||
<Card className={baseClass} borderRadiusSize="xxlarge" paddingSize="xlarge">
|
||||
<div className={`${baseClass}__header`}>
|
||||
<CardHeader header="Queries" />
|
||||
{canAddQuery && (
|
||||
<Button variant="inverse" onClick={onClickAddQuery} size="small">
|
||||
<Icon name="plus" />
|
||||
Add query
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renderHostQueries()}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import React from "react";
|
||||
|
||||
import { IQueryStats } from "interfaces/query_stats";
|
||||
import { getPerformanceImpactDescription } from "utilities/helpers";
|
||||
|
||||
import TooltipTruncatedTextCell from "components/TableContainer/DataTable/TooltipTruncatedTextCell";
|
||||
import PerformanceImpactCell from "components/TableContainer/DataTable/PerformanceImpactCell";
|
||||
import HeaderCell from "components/TableContainer/DataTable/HeaderCell";
|
||||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
import ReportUpdatedCell from "pages/hosts/details/cards/Queries/ReportUpdatedCell";
|
||||
import Icon from "components/Icon";
|
||||
import { Link } from "react-router";
|
||||
import PATHS from "router/paths";
|
||||
|
||||
interface IHostQueriesTableData extends Partial<IQueryStats> {
|
||||
performance: { indicator: string; id: number };
|
||||
should_link_to_hqr: boolean;
|
||||
id: number;
|
||||
}
|
||||
@@ -64,69 +59,34 @@ const generateColumnConfigs = (
|
||||
): IDataColumn[] => {
|
||||
const cols: IDataColumn[] = [
|
||||
{
|
||||
title: "Query",
|
||||
Header: "Query",
|
||||
disableSortBy: true,
|
||||
accessor: "query_name",
|
||||
Cell: (cellProps: ICellProps) => (
|
||||
<TooltipTruncatedTextCell value={cellProps.cell.value} />
|
||||
),
|
||||
Header: (cellProps) => (
|
||||
<HeaderCell value="Name" isSortedDesc={cellProps.column.isSortedDesc} />
|
||||
),
|
||||
sortType: "caseInsensitive",
|
||||
},
|
||||
{
|
||||
Header: () => {
|
||||
return (
|
||||
<TooltipWrapper
|
||||
tipContent={
|
||||
<>
|
||||
This is the performance <br />
|
||||
impact on this host.
|
||||
</>
|
||||
}
|
||||
>
|
||||
Performance impact
|
||||
</TooltipWrapper>
|
||||
);
|
||||
},
|
||||
disableSortBy: true,
|
||||
accessor: "performance",
|
||||
Cell: (cellProps: IPerformanceImpactCell) => {
|
||||
const baseClass = "performance-cell";
|
||||
const queryId = cellProps.row.original.id;
|
||||
return (
|
||||
<span className={baseClass}>
|
||||
<PerformanceImpactCell
|
||||
value={cellProps.cell.value}
|
||||
customIdPrefix="query-perf-pill"
|
||||
isHostSpecific
|
||||
/>
|
||||
{!queryReportsDisabled &&
|
||||
cellProps.row.original.should_link_to_hqr &&
|
||||
hostId &&
|
||||
queryId && (
|
||||
// parent row has same onClick functionality but link here is required for keyboard accessibility
|
||||
<Link
|
||||
className={`${baseClass}__link`}
|
||||
title="link to host query report"
|
||||
to={PATHS.HOST_QUERY_REPORT(hostId, queryId)}
|
||||
>
|
||||
<Icon
|
||||
name="chevron-right"
|
||||
className={`${baseClass}__link-icon`}
|
||||
color="ui-fleet-black-75"
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// include the Report updated column if query reports are globally enabled
|
||||
if (!queryReportsDisabled) {
|
||||
cols.push({
|
||||
Header: "Report updated",
|
||||
Header: () => {
|
||||
return (
|
||||
<TooltipWrapper
|
||||
tipContent={
|
||||
<>
|
||||
Each query is updated based on an <br />
|
||||
individually set interval.
|
||||
</>
|
||||
}
|
||||
>
|
||||
Last updated
|
||||
</TooltipWrapper>
|
||||
);
|
||||
},
|
||||
disableSortBy: true,
|
||||
accessor: "last_fetched", // tbd - may change
|
||||
Cell: (cellProps: ICellProps) => {
|
||||
@@ -148,9 +108,6 @@ const enhanceScheduleData = (
|
||||
): IHostQueriesTableData[] => {
|
||||
return Object.values(query_stats).map((query) => {
|
||||
const {
|
||||
user_time,
|
||||
system_time,
|
||||
executions,
|
||||
query_name,
|
||||
scheduled_query_id,
|
||||
last_fetched,
|
||||
@@ -158,20 +115,9 @@ const enhanceScheduleData = (
|
||||
discard_data,
|
||||
automations_enabled,
|
||||
} = query;
|
||||
// getPerformanceImpactDescription takes aggregate p50 values
|
||||
// getPerformanceImpactDescription takes aggregate p50 values so we need to divide by total executions in order to show average performance per query execution
|
||||
const scheduledQueryPerformance = {
|
||||
user_time_p50: executions > 0 ? user_time / executions : 0,
|
||||
system_time_p50: executions > 0 ? system_time / executions : 0,
|
||||
total_executions: executions,
|
||||
};
|
||||
return {
|
||||
query_name,
|
||||
id: scheduled_query_id,
|
||||
performance: {
|
||||
indicator: getPerformanceImpactDescription(scheduledQueryPerformance),
|
||||
id: scheduled_query_id,
|
||||
},
|
||||
last_fetched,
|
||||
interval,
|
||||
discard_data,
|
||||
|
||||
+2
-2
@@ -60,7 +60,7 @@ describe("ReportUpdatedCell component", () => {
|
||||
|
||||
expect(screen.getByText(HUMAN_READABLE_DATETIME_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(/\d+.+ago/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/View report/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/View data/)).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", () => {
|
||||
const tenDaysAgo = new Date();
|
||||
@@ -79,6 +79,6 @@ describe("ReportUpdatedCell component", () => {
|
||||
|
||||
expect(screen.getByText(HUMAN_READABLE_DATETIME_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(/\d+.+ago/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/View report/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/View data/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,7 +115,7 @@ const ReportUpdatedCell = ({
|
||||
);
|
||||
};
|
||||
|
||||
const onClick = (): void => {
|
||||
const onClick = () => {
|
||||
hostId &&
|
||||
queryId &&
|
||||
browserHistory.push(PATHS.HOST_QUERY_REPORT(hostId, queryId));
|
||||
@@ -132,7 +132,7 @@ const ReportUpdatedCell = ({
|
||||
onClick={onClick}
|
||||
size="small"
|
||||
>
|
||||
<span>View report</span>
|
||||
<span className={`${baseClass}__view-report--text`}>View data</span>
|
||||
<Icon name="chevron-right" color="ui-fleet-black-75" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
.host-queries-card {
|
||||
@include vertical-page-tab-panel-layout;
|
||||
|
||||
// prevent layout shift if last page of paginated table
|
||||
// doesn't fill all the vertical space due to fewer rows.
|
||||
min-height: 305px;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.table-container__header {
|
||||
display: none;
|
||||
}
|
||||
@@ -8,17 +18,11 @@
|
||||
.data-table__table {
|
||||
thead {
|
||||
.query_name__header {
|
||||
min-width: $col-lg;
|
||||
min-width: $col-sm;
|
||||
}
|
||||
.last_fetched__header {
|
||||
display: table-cell;
|
||||
}
|
||||
@media (max-width: $break-md) {
|
||||
.last_fetched__header {
|
||||
display: none;
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
tr {
|
||||
@@ -27,7 +31,7 @@
|
||||
}
|
||||
|
||||
.query_name__cell {
|
||||
min-width: $col-lg;
|
||||
min-width: $col-sm;
|
||||
}
|
||||
.last_fetched__cell {
|
||||
.report-updated-cell {
|
||||
@@ -49,19 +53,23 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@media (max-width: $break-md) {
|
||||
.last_fetched__cell {
|
||||
|
||||
@media (min-width: $break-md) and (max-width: 1300px) {
|
||||
.report-updated-cell__view-report--text {
|
||||
display: none;
|
||||
width: 0;
|
||||
}
|
||||
.performance-cell__link-icon {
|
||||
display: inline-flex;
|
||||
align-self: center;
|
||||
width: initial;
|
||||
|
||||
td {
|
||||
max-width: 140px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-header {
|
||||
font-weight: $bold;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from "react";
|
||||
import classnames from "classnames";
|
||||
import { noop } from "lodash";
|
||||
|
||||
import { IHostEndUser } from "interfaces/host";
|
||||
|
||||
|
||||
@@ -250,19 +250,23 @@ const QueryDetailsPage = ({
|
||||
isTeamMaintainerOrTeamAdmin;
|
||||
|
||||
// Function instead of constant eliminates race condition with filteredQueriesPath
|
||||
const backToQueriesPath = () => {
|
||||
return (
|
||||
filteredQueriesPath ||
|
||||
getPathWithQueryParams(PATHS.MANAGE_QUERIES, {
|
||||
team_id: currentTeamId,
|
||||
})
|
||||
);
|
||||
const backPath = () => {
|
||||
if (filteredQueriesPath) return filteredQueriesPath;
|
||||
|
||||
if (hostId) return getPathWithQueryParams(PATHS.HOST_DETAILS(hostId));
|
||||
|
||||
return getPathWithQueryParams(PATHS.MANAGE_QUERIES, {
|
||||
team_id: currentTeamId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`${baseClass}__header-links`}>
|
||||
<BackButton text="Back to queries" path={backToQueriesPath()} />
|
||||
<BackButton
|
||||
text={hostId ? "Back to host details" : "Back to queries"}
|
||||
path={backPath()}
|
||||
/>
|
||||
</div>
|
||||
{!isLoading && !isApiError && (
|
||||
<>
|
||||
@@ -329,6 +333,7 @@ const QueryDetailsPage = ({
|
||||
router.push(
|
||||
getPathWithQueryParams(PATHS.EDIT_QUERY(queryId), {
|
||||
team_id: currentTeamId,
|
||||
host_id: hostId,
|
||||
})
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -176,6 +176,7 @@ const EditQueryPage = ({
|
||||
router.push(
|
||||
getPathWithQueryParams(location.pathname, {
|
||||
team_id: storedQuery?.team_id?.toString(),
|
||||
host_id: hostId,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -266,6 +267,7 @@ const EditQueryPage = ({
|
||||
router.push(
|
||||
getPathWithQueryParams(PATHS.QUERY_DETAILS(query.id), {
|
||||
team_id: query.team_id,
|
||||
host_id: hostId,
|
||||
})
|
||||
);
|
||||
renderFlash("success", "Query created!");
|
||||
@@ -370,15 +372,36 @@ const EditQueryPage = ({
|
||||
|
||||
// Function instead of constant eliminates race condition
|
||||
// Returns to queries details page, manage queries page with filters, or default manage queries page
|
||||
const backToQueriesPath = () =>
|
||||
queryId
|
||||
? getPathWithQueryParams(PATHS.QUERY_DETAILS(queryId), {
|
||||
team_id: currentTeamId,
|
||||
})
|
||||
: filteredQueriesPath ||
|
||||
getPathWithQueryParams(PATHS.MANAGE_QUERIES, {
|
||||
team_id: currentTeamId,
|
||||
});
|
||||
const backPath = () => {
|
||||
if (queryId) {
|
||||
return getPathWithQueryParams(PATHS.QUERY_DETAILS(queryId), {
|
||||
team_id: currentTeamId,
|
||||
host_id: hostId,
|
||||
});
|
||||
}
|
||||
|
||||
if (hostId) {
|
||||
return getPathWithQueryParams(PATHS.HOST_DETAILS(hostId));
|
||||
}
|
||||
|
||||
if (filteredQueriesPath) return filteredQueriesPath;
|
||||
|
||||
return getPathWithQueryParams(PATHS.MANAGE_QUERIES, {
|
||||
team_id: currentTeamId,
|
||||
});
|
||||
};
|
||||
|
||||
const backButtonText = () => {
|
||||
if (queryId) {
|
||||
return "Back to report";
|
||||
}
|
||||
|
||||
if (hostId) {
|
||||
return "Back to host details";
|
||||
}
|
||||
|
||||
return "Back to queries";
|
||||
};
|
||||
|
||||
const showSidebar =
|
||||
isSidebarOpen &&
|
||||
@@ -394,10 +417,7 @@ const EditQueryPage = ({
|
||||
<MainContent className={baseClass}>
|
||||
<>
|
||||
<div className={`${baseClass}__header-links`}>
|
||||
<BackButton
|
||||
text={queryId ? "Back to report" : "Back to queries"}
|
||||
path={backToQueriesPath()}
|
||||
/>
|
||||
<BackButton text={backButtonText()} path={backPath()} />
|
||||
</div>
|
||||
<EditQueryForm
|
||||
router={router}
|
||||
@@ -409,6 +429,7 @@ const EditQueryPage = ({
|
||||
queryIdForEdit={queryId}
|
||||
apiTeamIdForQuery={apiTeamIdForQuery}
|
||||
currentTeamId={currentTeamId}
|
||||
currentTeamName={teamNameForQuery}
|
||||
isStoredQueryLoading={isStoredQueryLoading}
|
||||
showOpenSchemaActionText={showOpenSchemaActionText}
|
||||
onOpenSchemaSidebar={onOpenSchemaSidebar}
|
||||
|
||||
@@ -84,6 +84,7 @@ interface IEditQueryFormProps {
|
||||
queryIdForEdit: number | null;
|
||||
apiTeamIdForQuery?: number;
|
||||
currentTeamId?: number;
|
||||
currentTeamName?: string;
|
||||
showOpenSchemaActionText: boolean;
|
||||
storedQuery: ISchedulableQuery | undefined;
|
||||
isStoredQueryLoading: boolean;
|
||||
@@ -120,6 +121,7 @@ const EditQueryForm = ({
|
||||
queryIdForEdit,
|
||||
apiTeamIdForQuery,
|
||||
currentTeamId,
|
||||
currentTeamName,
|
||||
showOpenSchemaActionText,
|
||||
storedQuery,
|
||||
isStoredQueryLoading,
|
||||
@@ -173,6 +175,7 @@ const EditQueryForm = ({
|
||||
isAnyTeamObserverPlus,
|
||||
config,
|
||||
isPremiumTier,
|
||||
isFreeTier,
|
||||
} = useContext(AppContext);
|
||||
|
||||
const savedQueryMode = !!queryIdForEdit;
|
||||
@@ -563,6 +566,30 @@ const EditQueryForm = ({
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderQueryTeam = (isEditing = false) => {
|
||||
if (isFreeTier) return null;
|
||||
|
||||
if (currentTeamName) {
|
||||
if (isEditing) {
|
||||
return (
|
||||
<p>
|
||||
Editing query for <strong>{currentTeamName}</strong> team.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p>
|
||||
Creating a new query for <strong>{currentTeamName}</strong> team.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return <p>Editing global query.</p>;
|
||||
}
|
||||
return <p>Creating a new global query.</p>;
|
||||
};
|
||||
|
||||
// Observers and observer+ of existing query
|
||||
const renderNonEditableForm = (
|
||||
<form className={`${baseClass}`}>
|
||||
@@ -572,6 +599,7 @@ const EditQueryForm = ({
|
||||
</h1>
|
||||
{renderAuthor()}
|
||||
</div>
|
||||
{renderQueryTeam()}
|
||||
<PageDescription
|
||||
className={`${baseClass}__query-description no-hover`}
|
||||
content={lastEditedQueryDescription}
|
||||
@@ -695,9 +723,9 @@ const EditQueryForm = ({
|
||||
<form className={baseClass} autoComplete="off">
|
||||
<div className={`${baseClass}__title-bar`}>
|
||||
{renderName()}
|
||||
|
||||
{savedQueryMode && renderAuthor()}
|
||||
</div>
|
||||
{renderQueryTeam(true)}
|
||||
{renderDescription()}
|
||||
<SQLEditor
|
||||
value={lastEditedQueryBody}
|
||||
@@ -945,6 +973,7 @@ const EditQueryForm = ({
|
||||
...updateQueryData,
|
||||
team_id: apiTeamIdForQuery,
|
||||
}}
|
||||
hostId={hostId}
|
||||
onExit={toggleSaveAsNewQueryModal}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -35,6 +35,7 @@ interface ISaveAsNewQueryModal {
|
||||
router: InjectedRouter;
|
||||
location: Location;
|
||||
initialQueryData: ICreateQueryRequestBody;
|
||||
hostId?: number;
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
@@ -62,6 +63,7 @@ const SaveAsNewQueryModal = ({
|
||||
router,
|
||||
location,
|
||||
initialQueryData,
|
||||
hostId,
|
||||
onExit,
|
||||
}: ISaveAsNewQueryModal) => {
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
@@ -161,6 +163,7 @@ const SaveAsNewQueryModal = ({
|
||||
router.push(
|
||||
getPathWithQueryParams(PATHS.QUERY_DETAILS(newQuery.id), {
|
||||
team_id: newQuery.team_id,
|
||||
host_id: hostId,
|
||||
})
|
||||
);
|
||||
} catch (createError: unknown) {
|
||||
|
||||
+4
-17
@@ -13,28 +13,15 @@ const AuthAnyMaintainerAdminObserverPlusRoutes = ({
|
||||
children,
|
||||
}: IAuthAnyMaintainerAdminObserverPlusRoutesProps) => {
|
||||
const handlePageError = useErrorHandler();
|
||||
const {
|
||||
currentUser,
|
||||
isGlobalAdmin,
|
||||
isGlobalMaintainer,
|
||||
isAnyTeamAdmin,
|
||||
isAnyTeamMaintainer,
|
||||
isAnyTeamObserverPlus,
|
||||
isObserverPlus,
|
||||
} = useContext(AppContext);
|
||||
const { currentUser, isAnyMaintainerAdminObserverPlus } = useContext(
|
||||
AppContext
|
||||
);
|
||||
|
||||
if (!currentUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
!isGlobalAdmin &&
|
||||
!isGlobalMaintainer &&
|
||||
!isAnyTeamAdmin &&
|
||||
!isAnyTeamMaintainer &&
|
||||
!isObserverPlus &&
|
||||
!isAnyTeamObserverPlus
|
||||
) {
|
||||
if (!isAnyMaintainerAdminObserverPlus) {
|
||||
handlePageError({ status: 403 });
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -277,9 +277,6 @@ const routes = (
|
||||
<Route path="inventory" component={HostDetailsPage} />
|
||||
<Route path="library" component={HostDetailsPage} />
|
||||
</Route>
|
||||
|
||||
<Route path="queries" component={HostDetailsPage} />
|
||||
<Route path=":query_id" component={HostQueryReport} />
|
||||
<Route path="policies" component={HostDetailsPage} />
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -25,6 +25,11 @@ import { QueryParams, buildQueryStringFromParams } from "utilities/url";
|
||||
import { IHost } from "interfaces/host";
|
||||
import { ILabel } from "interfaces/label";
|
||||
import { IPack } from "interfaces/pack";
|
||||
import type { PerformanceImpactIndicator } from "interfaces/schedulable_query";
|
||||
import {
|
||||
PerformanceImpactIndicatorValue,
|
||||
ISchedulableQueryStats,
|
||||
} from "interfaces/schedulable_query";
|
||||
import {
|
||||
IScheduledQuery,
|
||||
IPackQueryFormData,
|
||||
@@ -49,7 +54,6 @@ import {
|
||||
PLATFORM_LABEL_DISPLAY_TYPES,
|
||||
isPlatformLabelNameFromAPI,
|
||||
} from "utilities/constants";
|
||||
import { ISchedulableQueryStats } from "interfaces/schedulable_query";
|
||||
import { IDropdownOption } from "interfaces/dropdownOption";
|
||||
import CustomLink from "components/CustomLink";
|
||||
|
||||
@@ -658,13 +662,13 @@ export const readableDate = (date: string) => {
|
||||
|
||||
export const getPerformanceImpactDescription = (
|
||||
scheduledQueryStats: ISchedulableQueryStats
|
||||
) => {
|
||||
): PerformanceImpactIndicator => {
|
||||
if (
|
||||
!scheduledQueryStats.total_executions ||
|
||||
scheduledQueryStats.total_executions === 0 ||
|
||||
scheduledQueryStats.total_executions === null
|
||||
) {
|
||||
return "Undetermined";
|
||||
return PerformanceImpactIndicatorValue.UNDETERMINED;
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -675,13 +679,59 @@ export const getPerformanceImpactDescription = (
|
||||
scheduledQueryStats.user_time_p50 + scheduledQueryStats.system_time_p50;
|
||||
|
||||
if (indicator < 2000) {
|
||||
return "Minimal";
|
||||
return PerformanceImpactIndicatorValue.MINIMAL;
|
||||
}
|
||||
if (indicator < 4000) {
|
||||
return "Considerable";
|
||||
return PerformanceImpactIndicatorValue.CONSIDERABLE;
|
||||
}
|
||||
}
|
||||
return "Excessive";
|
||||
return PerformanceImpactIndicatorValue.EXCESSIVE;
|
||||
};
|
||||
|
||||
export const getPerformanceImpactIndicatorTooltip = (
|
||||
indicator: PerformanceImpactIndicator,
|
||||
isHostSpecific = false
|
||||
) => {
|
||||
switch (indicator) {
|
||||
case PerformanceImpactIndicatorValue.MINIMAL:
|
||||
return (
|
||||
<>
|
||||
Running this query very frequently has little to no <br /> impact on
|
||||
your device's performance.
|
||||
</>
|
||||
);
|
||||
case PerformanceImpactIndicatorValue.CONSIDERABLE:
|
||||
return (
|
||||
<>
|
||||
Running this query frequently can have a noticeable <br />
|
||||
impact on your device's performance.
|
||||
</>
|
||||
);
|
||||
case PerformanceImpactIndicatorValue.EXCESSIVE:
|
||||
return (
|
||||
<>
|
||||
Running this query, even infrequently, can have a <br />
|
||||
significant impact on your device's performance.
|
||||
</>
|
||||
);
|
||||
case PerformanceImpactIndicatorValue.DENYLISTED:
|
||||
return (
|
||||
<>
|
||||
This query has been <br /> stopped from running <br /> because of
|
||||
excessive <br /> resource consumption.
|
||||
</>
|
||||
);
|
||||
case PerformanceImpactIndicatorValue.UNDETERMINED:
|
||||
return (
|
||||
<>
|
||||
Performance impact will be available when{" "}
|
||||
{isHostSpecific ? "the" : "this"} <br />
|
||||
query runs{isHostSpecific && " on this host"}.
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const secondsToDhms = (s: number): string => {
|
||||
|
||||
Reference in New Issue
Block a user