fleet/frontend/utilities/helpers.tsx
Nico 2e70ad2955
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"
/>
2026-01-02 10:06:12 -03:00

1018 lines
26 KiB
TypeScript

import React from "react";
import {
isEmpty,
flatMap,
omit,
pick,
memoize,
reduce,
trim,
trimEnd,
union,
uniqueId,
} from "lodash";
import md5 from "js-md5";
import {
formatDistanceToNow,
formatDuration,
intlFormat,
intervalToDuration,
isAfter,
addDays,
} from "date-fns";
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,
} from "interfaces/scheduled_query";
import {
ISelectTargetsEntity,
ISelectedTargetsForApi,
IPackTargets,
} from "interfaces/target";
import { ITeam } from "interfaces/team";
import { UserRole } from "interfaces/user";
import stringUtils from "utilities/strings";
import sortUtils from "utilities/sort";
import {
DEFAULT_EMPTY_CELL_VALUE,
DEFAULT_GRAVATAR_LINK,
DEFAULT_GRAVATAR_LINK_FALLBACK,
DEFAULT_GRAVATAR_LINK_DARK,
DEFAULT_GRAVATAR_LINK_DARK_FALLBACK,
INITIAL_FLEET_DATE,
PLATFORM_LABEL_DISPLAY_TYPES,
isPlatformLabelNameFromAPI,
} from "utilities/constants";
import { IDropdownOption } from "interfaces/dropdownOption";
import CustomLink from "components/CustomLink";
const ORG_INFO_ATTRS = ["org_name", "org_logo_url"];
const ADMIN_ATTRS = ["email", "name", "password", "password_confirmation"];
export const addGravatarUrlToResource = (resource: any): any => {
const { email } = resource;
const gravatarAvailable =
localStorage.getItem("gravatar_available") !== "false"; // Only fallback if explicitly set to "false"
const emailHash = md5(email.toLowerCase());
let gravatar_url;
let gravatar_url_dark;
if (gravatarAvailable) {
gravatar_url = `https://www.gravatar.com/avatar/${emailHash}?d=${encodeURIComponent(
DEFAULT_GRAVATAR_LINK
)}&size=200`;
gravatar_url_dark = `https://www.gravatar.com/avatar/${emailHash}?d=${encodeURIComponent(
DEFAULT_GRAVATAR_LINK_DARK
)}&size=200`;
} else {
gravatar_url = DEFAULT_GRAVATAR_LINK_FALLBACK;
gravatar_url_dark = DEFAULT_GRAVATAR_LINK_DARK_FALLBACK;
}
return {
...resource,
gravatar_url,
gravatar_url_dark,
};
};
/** Removes Apple OS Prefix from host.os_version. */
export const removeOSPrefix = (version: string): string => {
return version.replace(/^(macOS |iOS |iPadOS )/i, "");
};
/** Returns 1 if first version is newer, -1 if first version is older, and 0 if equal */
export const compareVersions = (version1: string, version2: string) => {
const v1Parts = version1.split(".").map(Number);
const v2Parts = version2.split(".").map(Number);
const maxLength = Math.max(v1Parts.length, v2Parts.length);
// Create a new array with a length of maxLength, mapping each index to a comparison result
return (
Array.from({ length: maxLength }, (_, index) => {
// Retrieve the corresponding parts from v1Parts and v2Parts, defaulting to 0
const v1Part = v1Parts[index] || 0;
const v2Part = v2Parts[index] || 0;
// Compare the current parts and return -1, 1, or 0 based on the result
if (v1Part < v2Part) return -1;
if (v1Part > v2Part) return 1;
return 0;
})
// Use Array.find to return the first non-equal version number in the comparison array
.find((result) => result !== 0) ||
// If no difference is found, return 0 to indicate equal versions
0
);
};
const labelSlug = (label: ILabel): string => {
const { id, name } = label;
if (name === "All Hosts") {
return "all-hosts";
}
return `labels/${id}`;
};
const isLabel = (target: ISelectTargetsEntity) => {
return "label_type" in target;
};
const isHost = (target: ISelectTargetsEntity) => {
return "display_name" in target;
};
const filterTarget = (targetType: string) => {
return (target: ISelectTargetsEntity) => {
const id =
typeof target.id !== "number" ? parseInt(target.id, 10) : target.id;
if ("target_type" in target) {
return target.target_type === targetType && !isNaN(id) ? [id] : [];
}
switch (targetType) {
case "hosts":
return isHost(target) && !isNaN(id) ? [id] : [];
case "labels":
return isLabel(target) && !isNaN(id) ? [id] : [];
case "teams":
return !isHost(target) && !isLabel(target) && !isNaN(id) ? [id] : [];
default:
return [];
}
};
};
export const formatFloatAsPercentage = (float?: number): string => {
if (float === undefined) {
return DEFAULT_EMPTY_CELL_VALUE;
}
const formatter = Intl.NumberFormat("en-US", {
maximumSignificantDigits: 2,
style: "percent",
});
return formatter.format(float);
};
const formatLabelResponse = (response: any): ILabel[] => {
const labels = response.labels.map((label: ILabel) => {
let labelType = "custom";
if (isPlatformLabelNameFromAPI(label.display_text)) {
labelType = PLATFORM_LABEL_DISPLAY_TYPES[label.display_text];
}
return {
...label,
slug: labelSlug(label),
type: labelType,
target_type: "labels",
};
});
return labels;
};
export const formatSelectedTargetsForApi = (
selectedTargets: ISelectTargetsEntity[]
): ISelectedTargetsForApi => {
const targets = selectedTargets || [];
// TODO: can flatMap be removed?
const hostIds = flatMap(targets, filterTarget("hosts"));
const labelIds = flatMap(targets, filterTarget("labels"));
const teamIds = flatMap(targets, filterTarget("teams"));
return {
hosts: hostIds.sort(),
labels: labelIds.sort(),
teams: teamIds.sort(),
};
};
export const formatPackTargetsForApi = (
targets: ISelectTargetsEntity[]
): IPackTargets => {
const { hosts, labels, teams } = formatSelectedTargetsForApi(targets);
return { host_ids: hosts, label_ids: labels, team_ids: teams };
};
export const formatScheduledQueryForServer = (
scheduledQuery: IPackQueryFormData
) => {
const {
interval,
logging_type: loggingType,
pack_id: packID,
platform,
query_id: queryID,
shard,
} = scheduledQuery;
const result = omit(scheduledQuery, ["logging_type"]);
if (platform === "all") {
result.platform = "";
}
if (interval) {
result.interval = Number(interval);
}
if (loggingType) {
result.removed = loggingType === "differential";
result.snapshot = loggingType === "snapshot";
}
if (packID) {
result.pack_id = Number(packID);
}
if (queryID) {
result.query_id = Number(queryID);
}
if (shard) {
result.shard = Number(shard);
}
return result;
};
export const formatScheduledQueryForClient = (
scheduledQuery: IScheduledQuery
): IScheduledQuery => {
if (scheduledQuery.platform === "") {
scheduledQuery.platform = "all";
}
if (scheduledQuery.snapshot) {
scheduledQuery.logging_type = "snapshot";
} else {
scheduledQuery.snapshot = false;
if (scheduledQuery.removed === false) {
scheduledQuery.logging_type = "differential_ignore_removals";
} else {
// If both are unset, we should default to differential (like osquery does)
scheduledQuery.logging_type = "differential";
}
}
if (scheduledQuery.shard === null) {
scheduledQuery.shard = undefined;
}
return scheduledQuery;
};
export const formatGlobalScheduledQueryForServer = (
scheduledQuery: IScheduledQuery
): IScheduledQuery => {
const {
interval,
logging_type: loggingType,
platform,
query_id: queryID,
shard,
} = scheduledQuery;
const result = omit(scheduledQuery, ["logging_type"]);
if (platform === "all") {
result.platform = "";
}
if (interval) {
result.interval = Number(interval);
}
if (loggingType) {
result.removed = loggingType === "differential";
result.snapshot = loggingType === "snapshot";
}
if (queryID) {
result.query_id = Number(queryID);
}
if (shard) {
result.shard = Number(shard);
}
return result;
};
export const formatGlobalScheduledQueryForClient = (
scheduledQuery: IScheduledQuery
): IScheduledQuery => {
if (scheduledQuery.platform === "") {
scheduledQuery.platform = "all";
}
if (scheduledQuery.snapshot) {
scheduledQuery.logging_type = "snapshot";
} else {
scheduledQuery.snapshot = false;
if (scheduledQuery.removed === false) {
scheduledQuery.logging_type = "differential_ignore_removals";
} else {
// If both are unset, we should default to differential (like osquery does)
scheduledQuery.logging_type = "differential";
}
}
if (scheduledQuery.shard === null) {
scheduledQuery.shard = undefined;
}
return scheduledQuery;
};
export const formatTeamScheduledQueryForServer = (
scheduledQuery: IScheduledQuery
) => {
const {
interval,
logging_type: loggingType,
platform,
query_id: queryID,
shard,
team_id: teamID,
} = scheduledQuery;
const result = omit(scheduledQuery, ["logging_type"]);
if (platform === "all") {
result.platform = "";
}
if (interval) {
result.interval = Number(interval);
}
if (loggingType) {
result.removed = loggingType === "differential";
result.snapshot = loggingType === "snapshot";
}
if (queryID) {
result.query_id = Number(queryID);
}
if (shard) {
result.shard = Number(shard);
}
if (teamID) {
result.query_id = Number(teamID);
}
return result;
};
export const formatTeamScheduledQueryForClient = (
scheduledQuery: IScheduledQuery
): IScheduledQuery => {
if (scheduledQuery.platform === "") {
scheduledQuery.platform = "all";
}
if (scheduledQuery.snapshot) {
scheduledQuery.logging_type = "snapshot";
} else {
scheduledQuery.snapshot = false;
if (scheduledQuery.removed === false) {
scheduledQuery.logging_type = "differential_ignore_removals";
} else {
// If both are unset, we should default to differential (like osquery does)
scheduledQuery.logging_type = "differential";
}
}
if (scheduledQuery.shard === null) {
scheduledQuery.shard = undefined;
}
return scheduledQuery;
};
export const formatTeamForClient = (team: ITeam): ITeam => {
if (team.display_text === undefined) {
team.display_text = team.name;
}
return team;
};
export const formatPackForClient = (pack: IPack): IPack => {
pack.host_ids ||= [];
pack.label_ids ||= [];
pack.team_ids ||= [];
return pack;
};
export const formatSeverity = (float?: number | null): string => {
if (float === null || float === undefined) {
return DEFAULT_EMPTY_CELL_VALUE;
}
let severity = "";
if (float < 4.0) {
severity = "Low";
} else if (float < 7.0) {
severity = "Medium";
} else if (float < 9.0) {
severity = "High";
} else if (float <= 10.0) {
severity = "Critical";
}
return `${severity} (${float.toFixed(1)})`;
};
export const formatScriptNameForActivityItem = (name: string | undefined) => {
return name ? (
<>
the <b>{name}</b> script
</>
) : (
"a script"
);
};
export const generateRole = (
teams: ITeam[],
globalRole: UserRole | null
): UserRole => {
if (globalRole === null) {
const listOfRoles = teams.map<UserRole | undefined>((team) => team.role);
if (teams.length === 0) {
// no global role and no teams
return "Unassigned";
} else if (teams.length === 1) {
// no global role and only one team
return stringUtils.capitalizeRole(teams[0].role || "Unassigned");
} else if (listOfRoles.every((role): boolean => role === "maintainer")) {
// only team maintainers
return "Maintainer";
} else if (listOfRoles.every((role): boolean => role === "observer")) {
// only team observers
return "Observer";
} else if (listOfRoles.every((role): boolean => role === "observer_plus")) {
// only team observers plus
return "Observer+";
}
return "Various"; // no global role and multiple teams
}
if (teams.length === 0) {
// global role and no teams
return stringUtils.capitalizeRole(globalRole);
}
return "Various"; // global role and one or more teams
};
export const generateTeam = (
teams: ITeam[],
globalRole: UserRole | null
): string => {
if (globalRole === null) {
if (teams.length === 0) {
// no global role and no teams
return "No team";
} else if (teams.length === 1) {
// no global role and only one team
return teams[0].name;
}
return `${teams.length} teams`; // no global role and multiple teams
}
if (teams.length === 0) {
// global role and no teams
return "Global";
}
return `${teams.length + 1} teams`; // global role and one or more teams
};
export const greyCell = (roleOrTeamText: string): boolean => {
const GREYED_TEXT = ["Global", "Unassigned", "Various", "No team", "Unknown"];
return (
GREYED_TEXT.includes(roleOrTeamText) || roleOrTeamText.includes(" teams")
);
};
const setupData = (formData: any) => {
const orgInfo = pick(formData, ORG_INFO_ATTRS);
const adminInfo = pick(formData, ADMIN_ATTRS);
return {
server_url: formData.server_url,
org_info: {
...orgInfo,
},
admin: {
admin: true,
...adminInfo,
},
};
};
const BYTES_PER_GIGABYTE = 1074000000;
const NANOSECONDS_PER_MILLISECOND = 1000000;
const inGigaBytes = (bytes: number): string => {
return (bytes / BYTES_PER_GIGABYTE).toFixed(1);
};
export const inMilliseconds = (nanoseconds: number): number => {
return nanoseconds / NANOSECONDS_PER_MILLISECOND;
};
export const humanHostLastSeen = (lastSeen: string): string => {
if (!lastSeen || lastSeen < INITIAL_FLEET_DATE) {
return "Never";
}
if (lastSeen === "Unavailable") {
return "Unavailable";
}
return formatDistanceToNow(new Date(lastSeen), { addSuffix: true });
};
export const humanHostEnrolled = (enrolled: string): string => {
if (!enrolled || enrolled < INITIAL_FLEET_DATE) {
return "Never";
}
return formatDistanceToNow(new Date(enrolled), { addSuffix: true });
};
export const humanHostMemory = (bytes: number): string => {
return `${inGigaBytes(bytes)} GB`;
};
export const humanHostDetailUpdated = (detailUpdated?: string): string => {
// Handles the case when a host has checked in to Fleet but
// its details haven't been updated.
if (!detailUpdated || detailUpdated < INITIAL_FLEET_DATE) {
return "unavailable";
}
try {
return formatDistanceToNow(new Date(detailUpdated), { addSuffix: true });
} catch {
return "unavailable";
}
};
/** Unlike humanHost helper functions, there are no Fleet-related date restrictions */
export const humanLastSeen = (lastSeen: string): string => {
if (!lastSeen) {
return "Never";
}
if (lastSeen === "Unavailable") {
return "Unavailable";
}
return formatDistanceToNow(new Date(lastSeen), { addSuffix: true });
};
export const internationalTimeFormat = (date: number | Date): string => {
return intlFormat(
date,
{
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
},
{ locale: window.navigator.languages[0] }
);
};
export const internationalNumberFormat = (number: number): string => {
return new Intl.NumberFormat(navigator.language).format(number);
};
export const hostTeamName = (teamName: string | null): string => {
if (!teamName) {
return "No team";
}
return teamName;
};
export const humanQueryLastRun = (lastRun: string): string => {
// Handles the case when a query has never been ran.
if (!lastRun || lastRun < INITIAL_FLEET_DATE) {
return "Has not run";
}
try {
return formatDistanceToNow(new Date(lastRun), { addSuffix: true });
} catch {
return "Unavailable";
}
};
export const hasLicenseExpired = (expiration: string): boolean => {
return isAfter(new Date(), new Date(expiration));
};
// just a rename of hasLicenseExpired so that it can be used in other contexts.
// TODO: change hasLicenseExpired instances to hasExpired
/**
* determines if a date has expired. This will check against the current date and time.
*/
export const hasExpired = hasLicenseExpired;
/**
* determines if a date will expire within "x" number of days. If the date has
* has already expired, this function will return false.
*/
export const willExpireWithinXDays = (expiration: string, x: number) => {
const xDaysFromNow = addDays(new Date(), x);
return (
!hasLicenseExpired(expiration) &&
isAfter(xDaysFromNow, new Date(expiration))
);
};
export const readableDate = (date: string) => {
const dateString = new Date(date);
return new Intl.DateTimeFormat(navigator.language, {
year: "numeric",
month: "long",
day: "numeric",
}).format(dateString);
};
export const getPerformanceImpactDescription = (
scheduledQueryStats: ISchedulableQueryStats
): PerformanceImpactIndicator => {
if (
!scheduledQueryStats.total_executions ||
scheduledQueryStats.total_executions === 0 ||
scheduledQueryStats.total_executions === null
) {
return PerformanceImpactIndicatorValue.UNDETERMINED;
}
if (
typeof scheduledQueryStats.user_time_p50 === "number" &&
typeof scheduledQueryStats.system_time_p50 === "number"
) {
const indicator =
scheduledQueryStats.user_time_p50 + scheduledQueryStats.system_time_p50;
if (indicator < 2000) {
return PerformanceImpactIndicatorValue.MINIMAL;
}
if (indicator < 4000) {
return PerformanceImpactIndicatorValue.CONSIDERABLE;
}
}
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 => {
if (s === 604800) {
return "1 week";
}
const duration = intervalToDuration({ start: 0, end: s * 1000 });
return formatDuration(duration);
};
export const secondsToHms = (d: number): string => {
const h = Math.floor(d / 3600);
const m = Math.floor((d % 3600) / 60);
const s = Math.floor((d % 3600) % 60);
const hDisplay = h > 0 ? h + (h === 1 ? " hr " : " hrs ") : "";
const mDisplay = m > 0 ? m + (m === 1 ? " min " : " mins ") : "";
const sDisplay = s > 0 ? s + (s === 1 ? " sec" : " secs") : "";
return hDisplay + mDisplay + sDisplay;
};
export const abbreviateTimeUnits = (str: string): string =>
str.replace("minute", "min").replace("second", "sec");
// TODO: Type any because ts files missing the following properties from type 'JSON': parse, stringify, [Symbol.toStringTag]
export const syntaxHighlight = (json: any): string => {
let jsonStr: string = JSON.stringify(json, undefined, 2);
jsonStr = jsonStr
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
/* eslint-disable no-useless-escape */
return jsonStr.replace(
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,
(match) => {
let cls = "number";
if (/^"/.test(match)) {
if (/:$/.test(match)) {
cls = "key";
} else {
cls = "string";
}
} else if (/true|false/.test(match)) {
cls = "boolean";
} else if (/null/.test(match)) {
cls = "null";
}
return `<span class="${cls}">${match}</span>`;
}
);
/* eslint-enable no-useless-escape */
};
export const tooltipTextWithLineBreaks = (lines: string[]) => {
return lines.map((line) => {
return (
<span key={uniqueId()}>
{line}
<br />
</span>
);
});
};
export const getSortedTeamOptions = memoize((teams: ITeam[]) =>
teams
.map((team) => {
return {
disabled: false,
label: team.name,
value: team.id,
};
})
.sort((a, b) => sortUtils.caseInsensitiveAsc(a.label, b.label))
);
// returns a mixture of props from host
export const normalizeEmptyValues = (
hostData: Partial<IHost>
): Record<
string,
number | string | boolean | Record<string, number | string | boolean>
> => {
return reduce(
hostData,
(result, value, key) => {
if (
(Number.isFinite(value) && value !== 0) ||
!isEmpty(value) ||
typeof value === "boolean"
) {
Object.assign(result, { [key]: value });
} else {
Object.assign(result, { [key]: DEFAULT_EMPTY_CELL_VALUE });
}
return result;
},
{}
);
};
export const wait = (milliseconds: number) =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
export const wrapFleetHelper = (
helperFn: (value: any) => string, // TODO: replace any with unknown and improve type narrowing by callers
value: string
): string => {
return value === DEFAULT_EMPTY_CELL_VALUE ? value : helperFn(value);
};
interface ILocationParams {
pathPrefix?: string;
routeTemplate?: string;
routeParams?: { [key: string]: string };
queryParams?: QueryParams;
}
type RouteParams = Record<string, string>;
const createRouteString = (routeTemplate: string, routeParams: RouteParams) => {
let routeString = "";
if (!isEmpty(routeParams)) {
routeString = reduce(
routeParams,
(string, value, key) => {
return string.replace(`:${key}`, encodeURIComponent(value));
},
routeTemplate
);
}
return routeString;
};
export const getNextLocationPath = ({
pathPrefix = "",
routeTemplate = "",
routeParams = {},
queryParams = {},
}: ILocationParams): string => {
const routeString = createRouteString(routeTemplate, routeParams);
const queryString = buildQueryStringFromParams(queryParams);
const nextLocation = trimEnd(
union(trim(pathPrefix, "/").split("/"), routeString.split("/")).join("/"),
"/"
);
return queryString ? `/${nextLocation}?${queryString}` : `/${nextLocation}`;
};
export const getSoftwareBundleTooltipJSX = (bundle: string) => (
<span>
<b>Bundle identifier: </b>
<br />
{bundle}
</span>
);
export const internallyTruncateText = (
original: string,
prefixLength = 280,
suffixLength = 10
): JSX.Element => (
<>
{original.slice(0, prefixLength)}...
{original.slice(original.length - suffixLength)} <em>(truncated)</em>
</>
);
/** Generates a mapping of unique column names present in the data to
* whether or not each of those columns contains exclusively number values. This allows the calling
* config generator to determine both which unique columns are present, and whether to sort each of them as
* alphanumeric (number type columns) or case-insensitive (everything else) */
export const getUniqueColsAreNumTypeFromRows = <
T extends Record<keyof T, unknown>
>(
rows: T[]
) => {
const m = new Map<keyof T, boolean>();
rows.forEach((row) => {
Object.entries(row).forEach(([name, val]) => {
const isNum = !isNaN(Number(val));
// keyof T will always actually be a string. This generic is helpful for upstream typing,
// but we can safely consider them interchangeagle.
const castName = name as keyof T;
if (!m.has(castName)) {
m.set(castName, isNum);
} else if (!isNum) {
// column name has already been seen and current val isn't a number
m.set(castName, false);
}
});
});
return m;
};
// can allow additional dropdown value types in the future
type DropdownOptionValue = IDropdownOption["value"];
export function getCustomDropdownOptions(
defaultOptions: IDropdownOption[],
customValue: DropdownOptionValue,
labelFormatter: (value: DropdownOptionValue) => string
): IDropdownOption[] {
return defaultOptions.some((option) => option.value === customValue)
? defaultOptions
: [
{ label: labelFormatter(customValue), value: customValue },
...defaultOptions,
];
}
export const getGitOpsModeTipContent = (repoURL: string) => {
let url = "";
try {
url = new URL(repoURL).toString();
} catch {
// Invalid URL submitted before validation was required, missing protocol
url = `https://${repoURL}`;
}
return (
<>
<span>
Manage in{" "}
<CustomLink newTab text="YAML" variant="tooltip-link" url={url} />
<br />
</span>
<span>(GitOps mode enabled)</span>
</>
);
};
/** Returns true if the passed in ISO 8601 date-time string represents a date and time in the past,
* false otherwise */
export const isDateTimePast = (dt: string) => {
return new Date(dt) < new Date();
};
export default {
addGravatarUrlToResource,
removeOSPrefix,
compareVersions,
formatLabelResponse,
formatFloatAsPercentage,
formatSeverity,
formatScheduledQueryForClient,
formatScheduledQueryForServer,
formatScriptNameForActivityItem,
formatGlobalScheduledQueryForClient,
formatGlobalScheduledQueryForServer,
formatTeamScheduledQueryForClient,
formatTeamScheduledQueryForServer,
formatSelectedTargetsForApi,
formatPackTargetsForApi,
generateRole,
generateTeam,
getUniqueColsAreNumTypeFromRows,
getCustomDropdownOptions,
greyCell,
humanHostLastSeen,
humanHostEnrolled,
humanHostMemory,
humanHostDetailUpdated,
humanLastSeen,
internationalTimeFormat,
internallyTruncateText,
hostTeamName,
humanQueryLastRun,
inMilliseconds,
hasLicenseExpired,
willExpireWithinXDays,
readableDate,
secondsToHms,
secondsToDhms,
labelSlug,
setupData,
syntaxHighlight,
normalizeEmptyValues,
wait,
wrapFleetHelper,
isDateTimePast,
};