mirror of
https://github.com/fleetdm/fleet
synced 2026-05-20 07:29:08 +00:00
* create a MainContent and SidePanelContent containers for layout this creates these two new components for handling layout more cleanly. It also allows us to put in common components into main layout, like sandbox expiration notification * use MainContent and SidePanelContent in current pages this brings in the two new components and wraps the page contents in these. This also allowed us to clean up and remove unused/no needed styling code * add MainContent component to user settings page and clean up user settings component this cleans up the user settings page to follow the panel convention we have as well as adds the MainContent component to this page * add MainContent component to team pages * update Sandbox gate to render optional component when in sandbox mode and add to MainContent * add call to sandbox api to get expiry time this adds a conditional call when the user is in sandbox mode to get the expiry time of the instance * fix sticky elements on settings pages to work with sandbox expiry message * fix e2e test after MainContent refactor
431 lines
12 KiB
TypeScript
431 lines
12 KiB
TypeScript
import React, { useContext, useEffect, useState } from "react";
|
|
import { Row } from "react-table";
|
|
import { useQuery } from "react-query";
|
|
import { useDebouncedCallback } from "use-debounce/lib";
|
|
|
|
import { AppContext } from "context/app";
|
|
|
|
import { IHost } from "interfaces/host";
|
|
import { ILabel, ILabelSummary } from "interfaces/label";
|
|
import {
|
|
ITarget,
|
|
ISelectLabel,
|
|
ISelectTeam,
|
|
ISelectTargetsEntity,
|
|
ISelectedTargets,
|
|
} from "interfaces/target";
|
|
import { ITeam } from "interfaces/team";
|
|
|
|
import labelsAPI, { ILabelsSummaryResponse } from "services/entities/labels";
|
|
import targetsAPI, {
|
|
ITargetsCountResponse,
|
|
ITargetsSearchResponse,
|
|
} from "services/entities/targets";
|
|
import teamsAPI, { ILoadTeamsResponse } from "services/entities/teams";
|
|
import { formatSelectedTargetsForApi } from "utilities/helpers";
|
|
|
|
import PageError from "components/DataError";
|
|
import TargetsInput from "components/LiveQuery/TargetsInput";
|
|
import Button from "components/buttons/Button";
|
|
import Spinner from "components/Spinner";
|
|
import TooltipWrapper from "components/TooltipWrapper";
|
|
import PlusIcon from "../../../assets/images/icon-plus-purple-32x32@2x.png";
|
|
import CheckIcon from "../../../assets/images/icon-check-purple-32x32@2x.png";
|
|
|
|
interface ITargetPillSelectorProps {
|
|
entity: ISelectLabel | ISelectTeam;
|
|
isSelected: boolean;
|
|
onClick: (
|
|
value: ISelectLabel | ISelectTeam
|
|
) => React.MouseEventHandler<HTMLButtonElement>;
|
|
}
|
|
|
|
interface ISelectTargetsProps {
|
|
baseClass: string;
|
|
queryId?: number | null;
|
|
selectedTargets: ITarget[];
|
|
targetedHosts: IHost[];
|
|
targetedLabels: ILabel[];
|
|
targetedTeams: ITeam[];
|
|
goToQueryEditor: () => void;
|
|
goToRunQuery: () => void;
|
|
setSelectedTargets: React.Dispatch<React.SetStateAction<ITarget[]>>;
|
|
setTargetedHosts: React.Dispatch<React.SetStateAction<IHost[]>>;
|
|
setTargetedLabels: React.Dispatch<React.SetStateAction<ILabel[]>>;
|
|
setTargetedTeams: React.Dispatch<React.SetStateAction<ITeam[]>>;
|
|
setTargetsTotalCount: React.Dispatch<React.SetStateAction<number>>;
|
|
}
|
|
|
|
interface ILabelsByType {
|
|
allHosts: ILabelSummary[];
|
|
platforms: ILabelSummary[];
|
|
other: ILabelSummary[];
|
|
}
|
|
|
|
interface ITargetsQueryKey {
|
|
scope: string;
|
|
query_id?: number | null;
|
|
query?: string | null;
|
|
selected?: ISelectedTargets | null;
|
|
}
|
|
|
|
const DEBOUNCE_DELAY = 500;
|
|
const STALE_TIME = 60000;
|
|
|
|
const isLabel = (entity: ISelectTargetsEntity) => "label_type" in entity;
|
|
|
|
const parseLabels = (list?: ILabelSummary[]) => {
|
|
const allHosts = list?.filter((l) => l.name === "All Hosts") || [];
|
|
const platforms =
|
|
list?.filter(
|
|
(l) =>
|
|
l.name === "macOS" || l.name === "MS Windows" || l.name === "All Linux"
|
|
) || [];
|
|
const other = list?.filter((l) => l.label_type === "regular") || [];
|
|
|
|
return { allHosts, platforms, other };
|
|
};
|
|
|
|
const TargetPillSelector = ({
|
|
entity,
|
|
isSelected,
|
|
onClick,
|
|
}: ITargetPillSelectorProps): JSX.Element => {
|
|
const displayText = () => {
|
|
switch (entity.name) {
|
|
case "All Hosts":
|
|
return "All hosts";
|
|
case "All Linux":
|
|
return "Linux";
|
|
default:
|
|
return entity.name || "Missing display name"; // TODO
|
|
}
|
|
};
|
|
|
|
return (
|
|
<button
|
|
className="target-pill-selector"
|
|
data-selected={isSelected}
|
|
onClick={(e) => onClick(entity)(e)}
|
|
>
|
|
<img
|
|
className={isSelected ? "check-icon" : "plus-icon"}
|
|
alt=""
|
|
src={isSelected ? CheckIcon : PlusIcon}
|
|
/>
|
|
<span className="selector-name">{displayText()}</span>
|
|
{/* <span className="selector-count">{entity.count}</span> */}
|
|
</button>
|
|
);
|
|
};
|
|
|
|
const SelectTargets = ({
|
|
baseClass,
|
|
queryId,
|
|
selectedTargets,
|
|
targetedHosts,
|
|
targetedLabels,
|
|
targetedTeams,
|
|
goToQueryEditor,
|
|
goToRunQuery,
|
|
setSelectedTargets,
|
|
setTargetedHosts,
|
|
setTargetedLabels,
|
|
setTargetedTeams,
|
|
setTargetsTotalCount,
|
|
}: ISelectTargetsProps): JSX.Element => {
|
|
const { isPremiumTier } = useContext(AppContext);
|
|
|
|
const [labels, setLabels] = useState<ILabelsByType | null>(null);
|
|
const [inputTabIndex, setInputTabIndex] = useState<number | null>(null);
|
|
const [searchText, setSearchText] = useState<string>("");
|
|
const [debouncedSearchText, setDebouncedSearchText] = useState<string>("");
|
|
const [isDebouncing, setIsDebouncing] = useState<boolean>(false);
|
|
|
|
const debounceSearch = useDebouncedCallback(
|
|
(search: string) => {
|
|
setDebouncedSearchText(search);
|
|
setIsDebouncing(false);
|
|
},
|
|
DEBOUNCE_DELAY,
|
|
{ trailing: true }
|
|
);
|
|
|
|
const {
|
|
data: teams,
|
|
error: errorTeams,
|
|
isLoading: isLoadingTeams,
|
|
} = useQuery<ILoadTeamsResponse, Error, ITeam[]>(
|
|
["teams"],
|
|
() => teamsAPI.loadAll(),
|
|
{
|
|
select: (data) => data.teams,
|
|
enabled: isPremiumTier,
|
|
staleTime: STALE_TIME, // TODO: confirm
|
|
}
|
|
);
|
|
|
|
const {
|
|
data: labelsSummary,
|
|
error: errorLabels,
|
|
isLoading: isLoadingLabels,
|
|
} = useQuery<ILabelsSummaryResponse, Error, ILabelSummary[]>(
|
|
["labelsSummary"],
|
|
labelsAPI.summary,
|
|
{
|
|
select: (data) => data.labels,
|
|
staleTime: STALE_TIME, // TODO: confirm
|
|
}
|
|
);
|
|
|
|
const {
|
|
data: searchResults,
|
|
isFetching: isFetchingSearchResults,
|
|
error: errorSearchResults,
|
|
} = useQuery<ITargetsSearchResponse, Error, IHost[], ITargetsQueryKey[]>(
|
|
[
|
|
{
|
|
scope: "targetsSearch", // TODO: shared scope?
|
|
query_id: queryId,
|
|
query: debouncedSearchText,
|
|
selected: formatSelectedTargetsForApi(selectedTargets),
|
|
},
|
|
],
|
|
({ queryKey }) => {
|
|
const { query_id, query, selected } = queryKey[0];
|
|
return targetsAPI.search({
|
|
query_id: query_id || null,
|
|
query: query || "",
|
|
excluded_host_ids: selected?.hosts || null,
|
|
});
|
|
},
|
|
{
|
|
select: (data) => data.hosts,
|
|
enabled: !!debouncedSearchText,
|
|
// staleTime: 5000, // TODO: try stale time if further performance optimizations are needed
|
|
}
|
|
);
|
|
|
|
const {
|
|
data: counts,
|
|
error: errorCounts,
|
|
isFetching: isFetchingCounts,
|
|
} = useQuery<
|
|
ITargetsCountResponse,
|
|
Error,
|
|
ITargetsCountResponse,
|
|
ITargetsQueryKey[]
|
|
>(
|
|
[
|
|
{
|
|
scope: "targetsCount", // Note: Scope is shared with QueryPage?
|
|
query_id: queryId,
|
|
selected: formatSelectedTargetsForApi(selectedTargets),
|
|
},
|
|
],
|
|
({ queryKey }) => {
|
|
const { query_id, selected } = queryKey[0];
|
|
return targetsAPI.count({ query_id, selected: selected || null });
|
|
},
|
|
{
|
|
enabled: !!selectedTargets.length,
|
|
onSuccess: (data) => {
|
|
setTargetsTotalCount(data.targets_count || 0);
|
|
},
|
|
staleTime: STALE_TIME, // TODO: confirm
|
|
}
|
|
);
|
|
|
|
useEffect(() => {
|
|
const selected = [...targetedHosts, ...targetedLabels, ...targetedTeams];
|
|
setSelectedTargets(selected);
|
|
}, [targetedHosts, targetedLabels, targetedTeams]);
|
|
|
|
useEffect(() => {
|
|
labelsSummary && setLabels(parseLabels(labelsSummary));
|
|
}, [labelsSummary]);
|
|
|
|
useEffect(() => {
|
|
if (inputTabIndex === null && labelsSummary && teams) {
|
|
setInputTabIndex(labelsSummary.length + teams.length || 0);
|
|
}
|
|
}, [inputTabIndex, labelsSummary, teams]);
|
|
|
|
useEffect(() => {
|
|
setIsDebouncing(true);
|
|
debounceSearch(searchText);
|
|
}, [searchText]);
|
|
|
|
const handleClickCancel = () => {
|
|
goToQueryEditor();
|
|
};
|
|
|
|
const handleButtonSelect = (selectedEntity: ISelectTargetsEntity) => (
|
|
e: React.MouseEvent<HTMLButtonElement>
|
|
): void => {
|
|
e.preventDefault();
|
|
|
|
const prevTargets: ISelectTargetsEntity[] = isLabel(selectedEntity)
|
|
? targetedLabels
|
|
: targetedTeams;
|
|
|
|
// if the target was previously selected, we want to remove it now
|
|
const newTargets = prevTargets.filter((t) => t.id !== selectedEntity.id);
|
|
// if the length remains the same, the target was not previously selected so we want to add it now
|
|
prevTargets.length === newTargets.length && newTargets.push(selectedEntity);
|
|
|
|
isLabel(selectedEntity)
|
|
? setTargetedLabels(newTargets as ILabel[])
|
|
: setTargetedTeams(newTargets as ITeam[]);
|
|
};
|
|
|
|
const handleRowSelect = (row: Row) => {
|
|
const selectedHost = row.original as IHost;
|
|
setTargetedHosts((prevHosts) => prevHosts.concat(selectedHost));
|
|
setSearchText("");
|
|
};
|
|
|
|
const handleRowRemove = (row: Row) => {
|
|
const removedHost = row.original as IHost;
|
|
setTargetedHosts((prevHosts) =>
|
|
prevHosts.filter((h) => h.id !== removedHost.id)
|
|
);
|
|
};
|
|
|
|
const onClickRun = () => {
|
|
setTargetsTotalCount(counts?.targets_count || 0);
|
|
goToRunQuery();
|
|
};
|
|
|
|
const renderTargetEntityList = (
|
|
header: string,
|
|
entityList: ISelectLabel[] | ISelectTeam[]
|
|
): JSX.Element => {
|
|
return (
|
|
<>
|
|
{header && <h3>{header}</h3>}
|
|
<div className="selector-block">
|
|
{entityList?.map((entity: ISelectLabel | ISelectTeam) => {
|
|
const targetList = isLabel(entity) ? targetedLabels : targetedTeams;
|
|
return (
|
|
<TargetPillSelector
|
|
key={`${isLabel(entity) ? "label" : "team"}__${entity.id}`}
|
|
entity={entity}
|
|
isSelected={targetList.some((t) => t.id === entity.id)}
|
|
onClick={handleButtonSelect}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const renderTargetsCount = (): JSX.Element | null => {
|
|
if (isFetchingCounts) {
|
|
return (
|
|
<>
|
|
<Spinner small />
|
|
<i style={{ color: "#8b8fa2" }}>Counting hosts</i>
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (errorCounts) {
|
|
return (
|
|
<b style={{ color: "#d66c7b", margin: 0 }}>
|
|
There was a problem counting hosts. Please try again later.
|
|
</b>
|
|
);
|
|
}
|
|
|
|
if (!counts) {
|
|
return null;
|
|
}
|
|
|
|
const { targets_count: total, targets_online: online } = counts;
|
|
const onlinePercentage = total > 0 ? Math.round((online / total) * 100) : 0;
|
|
|
|
return (
|
|
<>
|
|
<span>{total}</span> hosts targeted ({onlinePercentage}
|
|
%
|
|
<TooltipWrapper
|
|
tipContent={`Hosts are online if they<br /> have recently checked <br />into Fleet`}
|
|
>
|
|
online
|
|
</TooltipWrapper>
|
|
){" "}
|
|
</>
|
|
);
|
|
};
|
|
|
|
if (isLoadingLabels || (isPremiumTier && isLoadingTeams)) {
|
|
return (
|
|
<div className={`${baseClass}__wrapper body-wrap`}>
|
|
<h1>Select targets</h1>
|
|
<div className={`${baseClass}__page-loading`}>
|
|
<Spinner />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (errorLabels || errorTeams) {
|
|
return (
|
|
<div className={`${baseClass}__wrapper body-wrap`}>
|
|
<h1>Select targets</h1>
|
|
<PageError />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={`${baseClass}__wrapper`}>
|
|
<h1>Select targets</h1>
|
|
<div className={`${baseClass}__target-selectors`}>
|
|
{!!labels?.allHosts.length &&
|
|
renderTargetEntityList("", labels.allHosts)}
|
|
{!!labels?.platforms?.length &&
|
|
renderTargetEntityList("Platforms", labels.platforms)}
|
|
{!!teams?.length && renderTargetEntityList("Teams", teams)}
|
|
{!!labels?.other?.length &&
|
|
renderTargetEntityList("Labels", labels.other)}
|
|
</div>
|
|
<TargetsInput
|
|
tabIndex={inputTabIndex || 0}
|
|
searchText={searchText}
|
|
searchResults={searchResults || []}
|
|
isTargetsLoading={isFetchingSearchResults || isDebouncing}
|
|
targetedHosts={targetedHosts}
|
|
hasFetchError={!!errorSearchResults}
|
|
setSearchText={setSearchText}
|
|
handleRowSelect={handleRowSelect}
|
|
handleRowRemove={handleRowRemove}
|
|
/>
|
|
<div className={`${baseClass}__targets-button-wrap`}>
|
|
<Button
|
|
className={`${baseClass}__btn`}
|
|
onClick={handleClickCancel}
|
|
variant="text-link"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
className={`${baseClass}__btn`}
|
|
type="button"
|
|
variant="blue-green"
|
|
disabled={isFetchingCounts || !counts?.targets_count} // TODO: confirm
|
|
onClick={onClickRun}
|
|
>
|
|
Run
|
|
</Button>
|
|
<div className={`${baseClass}__targets-total-count`}>
|
|
{renderTargetsCount()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default SelectTargets;
|