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:
Nico
2026-01-02 10:06:12 -03:00
committed by GitHub
parent 96b9a4365c
commit 2e70ad2955
29 changed files with 423 additions and 697 deletions
@@ -0,0 +1,2 @@
- Remove Queries tab from Host Details page.
- Surface Queries within the Details tab.
@@ -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&apos;s performance.
</>
);
case "Considerable":
return (
<>
Running this query frequently can have a noticeable <br />
impact on your device&apos;s performance.
</>
);
case "Excessive":
return (
<>
Running this query, even infrequently, can have a <br />
significant impact on your device&apos;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 = ({
+9
View File
@@ -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[]
+19 -1
View File
@@ -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,
@@ -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,
})
);
}}
+34 -13
View File
@@ -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) {
@@ -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;
}
-3
View File
@@ -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>
+56 -6
View File
@@ -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&apos;s performance.
</>
);
case PerformanceImpactIndicatorValue.CONSIDERABLE:
return (
<>
Running this query frequently can have a noticeable <br />
impact on your device&apos;s performance.
</>
);
case PerformanceImpactIndicatorValue.EXCESSIVE:
return (
<>
Running this query, even infrequently, can have a <br />
significant impact on your device&apos;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 => {