UI: Merge scheduling functionality into queries page (#12713)

## Addresses #12636 

### See issue for list work done
![Screenshot 2023-07-12 at 6 47 04
PM](https://github.com/fleetdm/fleet/assets/61553566/47e3e5b2-0195-4f54-a377-8e5c03313acf)


![Frame-12-07-2023-06-43-32](https://github.com/fleetdm/fleet/assets/61553566/f72f2d41-609f-4409-8595-5f3e4f06d9bb)


### Notes for review:
- Because other work is based on this branch, TODOs / fixes are noted
here until the team comes to a strategy for merging all of the work:
- Add missing space in the Performance impact column "Undetermined"
tooltip text
- I'm having trouble confirming that the inherited queries table is
working right with the mock hard-coded data, though I did see it working
correctly previously. There's an issue with the page reverting to "All
teams" when trying to show the inherited table, though it does show the
table before re-rendering.

- This work is organized clearly by commit, so that might be a
manageable way to go through this code.
- Since the updated API for this work is not yet complete, this work can
be manually tested by either:
    - Using mock API infrastructure, or
- in `ManageQueriesPage.tsx`, comment out the two `useQuery` calls and
add appropriate mock data. You can then modify any fields of interest to
test their related UI functionality. For example, lines 119 -242 might
read:
```
// const {
  //   data: curTeamEnhancedQueries,
  //   error: curTeamQueriesError,
  //   isFetching: isFetchingCurTeamQueries,
  //   refetch: refetchCurTeamQueries,
  // } = useQuery<IListQueriesResponse, Error, IEnhancedQuery[]>(
  //   [{ scope: "queries", teamId: teamIdForApi }],
  //   () => queriesAPI.loadAll(teamIdForApi),
  //   {
  //     refetchOnWindowFocus: false,
  //     enabled: isRouteOk,
  //     select: (data) => data.queries.map(enhanceQuery),
  //   }
  // );

  // // If a team is selected, fetch inherited global queries as well
  // const {
  //   data: globalEnhancedQueries,
  //   error: globalQueriesError,
  //   isFetching: isFetchingGlobalQueries,
  //   refetch: refetchGlobalQueries,
  // } = useQuery<IListQueriesResponse, Error, IEnhancedQuery[]>(
  //   [{ scope: "queries", teamId: -1 }],
  //   () => queriesAPI.loadAll(),
  //   {
  //     refetchOnWindowFocus: false,
  //     enabled: isRouteOk && isAnyTeamSelected,
  //     select: (data) => data.queries.map(enhanceQuery),
  //   }
  // );

  const [
    curTeamEnhancedQueries,
    curTeamQueriesError,
    isFetchingCurTeamQueries,
    refetchCurTeamQueries,
  ] = useMemo(() => {
    return [
      [
        {
          created_at: "2023-06-08T15:31:35Z",
          updated_at: "2023-06-08T15:31:35Z",
          id: 2,
          name: "test",
          description: "",
          query: "SELECT * FROM osquery_info;",
          team_id: 43,
          platform: "darwin",
          min_osquery_version: "",
          automations_enabled: true,
          logging: "snapshot",
          saved: true,
          // interval: 300,
          interval: 0,
          observer_can_run: false,
          author_id: 1,
          author_name: "Jacob",
          author_email: "jacob@fleetdm.com",
          packs: [],
          stats: {
            // system_time_p50: 1,
            // system_time_p95: null,
            // user_time_p50: 1,
            // user_time_p95: null,
            // total_executions: 1,
          },
          performance: "Undetermined",
          platforms: ["darwin"],
        },
      ] as IEnhancedQuery[],
      undefined,
      false,
      () => {
        console.log("got the new queries");
      },
    ];
  }, []);

  const [
    globalEnhancedQueries,
    globalQueriesError,
    isFetchingGlobalQueries,
    refetchGlobalQueries,
  ] = useMemo(() => {
    return [
      [
        {
          created_at: "2023-06-08T15:31:35Z",
          updated_at: "2023-06-08T15:31:35Z",
          id: 200,
          name: "test",
          description: "",
          query: "SELECT * FROM osquery_info;",
          team_id: null,
          platform: "darwin",
          min_osquery_version: "",
          automations_enabled: true,
          logging: "snapshot",
          saved: true,
          // interval: 300,
          interval: 0,
          observer_can_run: false,
          author_id: 1,
          author_name: "Jacob",
          author_email: "jacob@fleetdm.com",
          packs: [],
          stats: {
            // system_time_p50: 1,
            // system_time_p95: null,
            // user_time_p50: 1,
            // user_time_p95: null,
            // total_executions: 1,
          },
          performance: "Undetermined",
          platforms: ["darwin"],
        },
      ] as IEnhancedQuery[],
      undefined,
      false,
      () => {
        console.log("got the new inherited queries");
      },
    ];
  }, []);
```

- [x] Changes file added for user-visible changes in `changes/`
- [x] Manual QA for all new/changed functionality

---------

Co-authored-by: Jacob Shandling <jacob@fleetdm.com>
This commit is contained in:
Jacob Shandling
2023-07-13 12:11:11 -07:00
committed by GitHub
co-authored by Jacob Shandling
parent 1d8d6d8c15
commit 7ff4b77fb9
39 changed files with 433 additions and 2223 deletions
@@ -0,0 +1 @@
- Merged all functionality of the Schedule page into the Queries page
@@ -7,7 +7,7 @@ interface IStatusIndicatorProps {
value: string;
tooltip?: {
id: number;
tooltipText: string;
tooltipText: string | JSX.Element;
position?: "top" | "bottom";
};
}
@@ -52,4 +52,24 @@
background-color: $ui-warning;
}
}
// Query automations status
&--on {
&:before {
background-color: $ui-success;
}
}
&--off {
&:before {
background-color: $ui-offline;
}
}
&--paused {
.status-tooltip {
text-transform: none;
}
&:before {
background-color: $ui-offline;
}
}
}
@@ -8,9 +8,7 @@ const PERFORMANCE_IMPACT = { indicator: "Minimal", id: 3 };
describe("Pill cell", () => {
it("renders pill text and tooltip on hover", async () => {
const { user } = renderWithSetup(
<PillCell value={PERFORMANCE_IMPACT} hostDetails />
);
const { user } = renderWithSetup(<PillCell value={PERFORMANCE_IMPACT} />);
await user.hover(screen.getByText("Minimal"));
@@ -7,18 +7,13 @@ import ReactTooltip from "react-tooltip";
interface IPillCellProps {
value: { indicator: string; id: number };
customIdPrefix?: string;
hostDetails?: boolean;
}
const generateClassTag = (rawValue: string): string => {
return rawValue.replace(" ", "-").toLowerCase();
};
const PillCell = ({
value,
customIdPrefix,
hostDetails,
}: IPillCellProps): JSX.Element => {
const PillCell = ({ value, customIdPrefix }: IPillCellProps): JSX.Element => {
const { indicator, id } = value;
const pillClassName = classnames(
"data-table__pill",
@@ -75,9 +70,8 @@ const PillCell = ({
case "Undetermined":
return (
<>
To see performance <br /> impact, this query must <br /> run as a
scheduled query <br /> on {hostDetails ? "this" : "at least one"}{" "}
host.
To see performance impact, this query must have run with
<b>automations</b> on at least one host.
</>
);
default:
@@ -1,4 +1,6 @@
import { uniqueId } from "lodash";
import React from "react";
import ReactTooltip from "react-tooltip";
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
interface ITextCellProps {
@@ -6,6 +8,7 @@ interface ITextCellProps {
formatter?: (val: any) => JSX.Element | string; // string, number, or null
greyed?: boolean;
classes?: string;
emptyCellTooltipText?: JSX.Element | string;
}
const TextCell = ({
@@ -13,6 +16,7 @@ const TextCell = ({
formatter = (val) => val, // identity function if no formatter is provided
greyed,
classes = "w250",
emptyCellTooltipText,
}: ITextCellProps): JSX.Element => {
let val = value;
@@ -22,9 +26,32 @@ const TextCell = ({
if (!val) {
greyed = true;
}
const renderEmptyCell = () => {
if (emptyCellTooltipText) {
const tooltipId = uniqueId();
return (
<>
<span data-tip data-for={tooltipId}>
{DEFAULT_EMPTY_CELL_VALUE}
</span>
<ReactTooltip
place="top"
effect="solid"
backgroundColor="#3e4771"
id={tooltipId}
>
{emptyCellTooltipText}
</ReactTooltip>
</>
);
}
return DEFAULT_EMPTY_CELL_VALUE;
};
return (
<span className={`text-cell ${classes} ${greyed && "grey-cell"}`}>
{formatter(val) || DEFAULT_EMPTY_CELL_VALUE}
{formatter(val) || renderEmptyCell()}
</span>
);
};
@@ -220,6 +220,9 @@ $shadow-transition-width: 10px;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0;
.__react_component_tooltip {
white-space: normal;
}
}
.w400 {
max-width: calc(400px - 48px);
@@ -234,6 +237,9 @@ $shadow-transition-width: 10px;
.grey-cell {
color: $ui-fleet-black-50;
font-style: italic;
.__react_component_tooltip {
font-style: normal;
}
}
}
@@ -47,7 +47,7 @@ const RevealButton = ({
{caretPosition === "before" && (
<Icon
name="chevron"
direction={isShowing ? "right" : "down"}
direction={isShowing ? "down" : "right"}
color="core-fleet-blue"
/>
)}
@@ -78,15 +78,6 @@ export default (
pathname: PATHS.MANAGE_QUERIES,
},
},
{
name: "Schedule",
location: {
regex: new RegExp(`^${URL_PREFIX}/(schedule|packs)/`),
pathname: PATHS.MANAGE_SCHEDULE,
},
exclude: !isMaintainerOrAdmin,
withParams: { type: "query", names: ["team_id"] },
},
{
name: "Policies",
location: {
@@ -54,22 +54,6 @@ describe("Host Actions Dropdown", () => {
});
});
it("renders the Query action as disabled if the host is offline", async () => {
const render = createCustomRenderer();
const { user } = render(
<HostActionsDropdown
onSelect={noop}
hostStatus="offline"
hostMdmEnrollemntStatus="On (automatic)"
/>
);
await user.click(screen.getByText("Actions"));
expect(screen.getByText("Query").parentNode).toHaveClass("is-disabled");
});
it("renders the Show Disk Encryption Key action when on premium tier and we store the disk encryption key", async () => {
const render = createCustomRenderer({
context: {
@@ -10,11 +10,6 @@ const DEFAULT_OPTIONS: IDropdownOption[] = [
disabled: false,
premiumOnly: true,
},
{
label: "Query",
value: "query",
disabled: false,
},
{
label: "Show disk encryption key",
value: "diskEncryption",
@@ -122,9 +117,7 @@ const setOptionsAsDisabled = (
let optionsToDisable: IDropdownOption[] = [];
if (!isHostOnline) {
optionsToDisable = optionsToDisable.concat(
options.filter(
(option) => option.value === "query" || option.value === "mdmOff"
)
options.filter((option) => option.value === "mdmOff")
);
}
if (isSandboxMode) {
@@ -9,7 +9,6 @@ import { pick } from "lodash";
import PATHS from "router/paths";
import hostAPI from "services/entities/hosts";
import queryAPI from "services/entities/queries";
import teamAPI, { ILoadTeamsResponse } from "services/entities/teams";
import { AppContext } from "context/app";
import { PolicyContext } from "context/policy";
@@ -18,14 +17,11 @@ import {
IHost,
IDeviceMappingResponse,
IMacadminsResponse,
IPackStats,
IHostResponse,
IHostMdmData,
} from "interfaces/host";
import { ILabel } from "interfaces/label";
import { IHostPolicy } from "interfaces/policy";
import { IQuery, IFleetQueriesResponse } from "interfaces/query";
import { IQueryStats } from "interfaces/query_stats";
import { ISoftware } from "interfaces/software";
import { ITeam } from "interfaces/team";
@@ -36,7 +32,6 @@ import InfoBanner from "components/InfoBanner";
import BackLink from "components/BackLink";
import { normalizeEmptyValues, wrapFleetHelper } from "utilities/helpers";
import permissions from "utilities/permissions";
import HostSummaryCard from "../cards/HostSummary";
import AboutCard from "../cards/About";
@@ -46,9 +41,6 @@ import MunkiIssuesCard from "../cards/MunkiIssues";
import SoftwareCard from "../cards/Software";
import UsersCard from "../cards/Users";
import PoliciesCard from "../cards/Policies";
import ScheduleCard from "../cards/Schedule";
import PacksCard from "../cards/Packs";
import SelectQueryModal from "./modals/SelectQueryModal";
import PolicyDetailsModal from "../cards/Policies/HostPoliciesTable/PolicyDetailsModal";
import OSPolicyModal from "./modals/OSPolicyModal";
import UnenrollMdmModal from "./modals/UnenrollMdmModal";
@@ -95,12 +87,6 @@ interface IHostDetailsSubNavItem {
pathname: string;
}
const TAGGED_TEMPLATES = {
queryByHostRoute: (hostId: number | undefined | null) => {
return `${hostId ? `?host_ids=${hostId}` : ""}`;
},
};
const HostDetailsPage = ({
route,
router,
@@ -113,9 +99,7 @@ const HostDetailsPage = ({
const {
config,
currentUser,
isGlobalAdmin = false,
isGlobalObserver,
isPremiumTier = false,
isSandboxMode,
isOnlyObserver,
@@ -135,7 +119,6 @@ const HostDetailsPage = ({
const [showDeleteHostModal, setShowDeleteHostModal] = useState(false);
const [showTransferHostModal, setShowTransferHostModal] = useState(false);
const [showSelectQueryModal, setShowSelectQueryModal] = useState(false);
const [showPolicyDetailsModal, setPolicyDetailsModal] = useState(false);
const [showOSPolicyModal, setShowOSPolicyModal] = useState(false);
const [showMacSettingsModal, setShowMacSettingsModal] = useState(false);
@@ -151,26 +134,11 @@ const HostDetailsPage = ({
const [refetchStartTime, setRefetchStartTime] = useState<number | null>(null);
const [showRefetchSpinner, setShowRefetchSpinner] = useState(false);
const [packsState, setPacksState] = useState<IPackStats[]>();
const [schedule, setSchedule] = useState<IQueryStats[]>();
const [hostSoftware, setHostSoftware] = useState<ISoftware[]>([]);
const [usersState, setUsersState] = useState<{ username: string }[]>([]);
const [usersSearchString, setUsersSearchString] = useState("");
const [pathname, setPathname] = useState("");
const { data: fleetQueries, error: fleetQueriesError } = useQuery<
IFleetQueriesResponse,
Error,
IQuery[]
>("fleet queries", () => queryAPI.loadAll(), {
enabled: !!hostIdFromURL,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
retry: false,
select: (data: IFleetQueriesResponse) => data.queries,
});
const { data: teams } = useQuery<ILoadTeamsResponse, Error, ITeam[]>(
"teams",
() => teamAPI.loadAll(),
@@ -294,27 +262,6 @@ const HostDetailsPage = ({
}
setHostSoftware(returnedHost.software || []);
setUsersState(returnedHost.users || []);
if (returnedHost.pack_stats) {
const packStatsByType = returnedHost.pack_stats.reduce(
(
dictionary: {
packs: IPackStats[];
schedule: IQueryStats[];
},
pack: IPackStats
) => {
if (pack.type === "pack") {
dictionary.packs.push(pack);
} else {
dictionary.schedule.push(...pack.query_stats);
}
return dictionary;
},
{ packs: [], schedule: [] }
);
setPacksState(packStatsByType.packs);
setSchedule(packStatsByType.schedule);
}
},
onError: (error) => handlePageError(error),
}
@@ -480,17 +427,6 @@ const HostDetailsPage = ({
: router.push(PATHS.MANAGE_HOSTS_LABEL(label.id));
};
const onQueryHostCustom = () => {
router.push(PATHS.NEW_QUERY + TAGGED_TEMPLATES.queryByHostRoute(host?.id));
};
const onQueryHostSaved = (selectedQuery: IQuery) => {
router.push(
PATHS.EDIT_QUERY(selectedQuery) +
TAGGED_TEMPLATES.queryByHostRoute(host?.id)
);
};
const onTransferHostSubmit = async (team: ITeam) => {
setIsUpdatingHost(true);
@@ -528,9 +464,6 @@ const HostDetailsPage = ({
case "transfer":
setShowTransferHostModal(true);
break;
case "query":
setShowSelectQueryModal(true);
break;
case "diskEncryption":
setShowDiskEncryptionModal(true);
break;
@@ -576,11 +509,6 @@ const HostDetailsPage = ({
title: "software",
pathname: PATHS.HOST_SOFTWARE(hostIdFromURL),
},
{
name: "Schedule",
title: "schedule",
pathname: PATHS.HOST_SCHEDULE(hostIdFromURL),
},
{
name: (
<>
@@ -615,23 +543,6 @@ const HostDetailsPage = ({
host?.mdm.name === "Fleet" &&
host?.mdm.macos_settings?.disk_encryption === "action_required";
/* 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,
@@ -738,16 +649,6 @@ const HostDetailsPage = ({
/>
)}
</TabPanel>
<TabPanel>
<ScheduleCard
isChromeOSHost={host?.platform === "chrome"}
schedule={schedule}
isLoading={isLoadingHost}
/>
{canViewPacks && (
<PacksCard packsState={packsState} isLoading={isLoadingHost} />
)}
</TabPanel>
<TabPanel>
<PoliciesCard
policies={host?.policies || []}
@@ -765,17 +666,6 @@ const HostDetailsPage = ({
isUpdating={isUpdatingHost}
/>
)}
{showSelectQueryModal && host && (
<SelectQueryModal
onCancel={() => setShowSelectQueryModal(false)}
queries={fleetQueries || []}
queryErrors={fleetQueriesError}
isOnlyObserver={isOnlyObserver}
onQueryHostCustom={onQueryHostCustom}
onQueryHostSaved={onQueryHostSaved}
hostsTeamId={host?.team_id}
/>
)}
{!!host && showTransferHostModal && (
<TransferHostModal
onCancel={() => setShowTransferHostModal(false)}
@@ -104,7 +104,6 @@ const generatePackTableHeaders = (): IDataColumn[] => {
<PillCell
value={cellProps.cell.value}
customIdPrefix="query-perf-pill"
hostDetails
/>
),
},
@@ -1,80 +0,0 @@
import React from "react";
import { IQueryStats } from "interfaces/query_stats";
import TableContainer from "components/TableContainer";
import EmptyTable from "components/EmptyTable";
import CustomLink from "components/CustomLink";
import { generateTableHeaders, generateDataSet } from "./ScheduleTableConfig";
const baseClass = "schedule";
interface IScheduleProps {
schedule?: IQueryStats[];
isChromeOSHost: boolean;
isLoading: boolean;
}
const Schedule = ({
schedule,
isChromeOSHost,
isLoading,
}: IScheduleProps): JSX.Element => {
const wrapperClassName = `${baseClass}__pack-table`;
const tableHeaders = generateTableHeaders();
const renderEmptyScheduleTab = () => {
if (isChromeOSHost) {
return (
<EmptyTable
header="Scheduled queries are not supported for this host"
info={
<>
<span>Interested in collecting data from your Chromebooks? </span>
<CustomLink
url="https://www.fleetdm.com/contact"
text="Let us know"
newTab
/>
</>
}
/>
);
}
return (
<EmptyTable
header="No queries are scheduled for this host"
info="Expecting to see queries? Try selecting “Refetch” to ask this host
to report new vitals."
/>
);
};
return (
<div className="section section--schedule">
<p className="section__header">Schedule</p>
{!schedule || !schedule.length || isChromeOSHost ? (
renderEmptyScheduleTab()
) : (
<div className={`${wrapperClassName}`}>
<TableContainer
columns={tableHeaders}
data={generateDataSet(schedule)}
isLoading={isLoading}
onQueryChange={() => null}
resultsTitle={"queries"}
defaultSortHeader={"scheduled_query_name"}
defaultSortDirection={"asc"}
showMarkAllPages={false}
isAllPagesSelected={false}
emptyComponent={() => <></>}
disablePagination
disableCount
/>
</div>
)}
</div>
);
};
export default Schedule;
@@ -1,123 +0,0 @@
import React from "react";
import { IQueryStats } from "interfaces/query_stats";
import { performanceIndicator, secondsToDhms } from "utilities/helpers";
import TextCell from "components/TableContainer/DataTable/TextCell";
import PillCell from "components/TableContainer/DataTable/PillCell";
import TooltipWrapper from "components/TooltipWrapper";
interface IHeaderProps {
column: {
title: string;
isSortedDesc: boolean;
};
}
interface IRowProps {
row: {
original: IQueryStats;
};
}
interface ICellProps extends IRowProps {
cell: {
value: string | number | boolean;
};
}
interface IPillCellProps extends IRowProps {
cell: {
value: {
indicator: string;
id: number;
};
};
}
interface IDataColumn {
title?: string;
Header: ((props: IHeaderProps) => JSX.Element) | string;
accessor: string;
Cell:
| ((props: ICellProps) => JSX.Element)
| ((props: IPillCellProps) => JSX.Element);
disableHidden?: boolean;
disableSortBy?: boolean;
}
interface IScheduleTable extends Partial<IQueryStats> {
frequency: string;
performance: { indicator: string; id: number };
}
// NOTE: cellProps come from react-table
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
const generateTableHeaders = (): IDataColumn[] => {
return [
{
title: "Query",
Header: "Query",
disableSortBy: true,
accessor: "query_name",
Cell: (cellProps: ICellProps) => (
<TextCell value={cellProps.cell.value} />
),
},
{
title: "Frequency",
Header: "Frequency",
disableSortBy: true,
accessor: "frequency",
Cell: (cellProps: ICellProps) => (
<TextCell value={cellProps.cell.value} />
),
},
{
Header: () => {
return (
<TooltipWrapper tipContent="This is the performance <br />impact on this host.">
Performance impact
</TooltipWrapper>
);
},
disableSortBy: true,
accessor: "performance",
Cell: (cellProps: IPillCellProps) => (
<PillCell
value={cellProps.cell.value}
customIdPrefix="query-perf-pill"
hostDetails
/>
),
},
];
};
const enhanceScheduleData = (query_stats: IQueryStats[]): IScheduleTable[] => {
return Object.values(query_stats).map((query) => {
const scheduledQueryPerformance = {
user_time_p50: query.user_time,
system_time_p50: query.system_time,
total_executions: query.executions,
};
return {
query_name: query.query_name,
frequency: secondsToDhms(query.interval),
performance: {
indicator: performanceIndicator(scheduledQueryPerformance),
id: query.scheduled_query_id,
},
};
});
};
const generateDataSet = (query_stats: IQueryStats[]): IScheduleTable[] => {
if (!query_stats) {
return query_stats;
}
return [...enhanceScheduleData(query_stats)];
};
export { generateTableHeaders, generateDataSet };
@@ -1,29 +0,0 @@
.section--schedule {
margin-top: $pad-medium;
.section__header {
margin-bottom: $pad-medium;
}
.table-container__header {
display: none;
}
.data-table-block {
.data-table__table {
thead {
.query_name__header {
width: $col-lg;
}
.frequency__header {
width: $col-md;
}
}
tbody {
.query_name__cell {
width: $col-lg;
}
.frequency__cell {
width: $col-md;
}
}
}
}
}
@@ -1 +0,0 @@
export { default } from "./Schedule";
@@ -5,7 +5,7 @@ import React, {
useMemo,
useState,
} from "react";
import { RouteProps, InjectedRouter } from "react-router";
import { InjectedRouter } from "react-router";
import { useQuery } from "react-query";
import { pick } from "lodash";
@@ -14,8 +14,11 @@ import { TableContext } from "context/table";
import { NotificationContext } from "context/notification";
import { performanceIndicator } from "utilities/helpers";
import { IOsqueryPlatform } from "interfaces/platform";
import { IQuery, IFleetQueriesResponse } from "interfaces/query";
import fleetQueriesAPI from "services/entities/queries";
import {
IListQueriesResponse,
ISchedulableQuery,
} from "interfaces/schedulable_query";
import queriesAPI from "services/entities/queries";
import PATHS from "router/paths";
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
import checkPlatformCompatibility from "utilities/sql_tools";
@@ -23,27 +26,31 @@ import Button from "components/buttons/Button";
import Spinner from "components/Spinner";
import TableDataError from "components/DataError";
import MainContent from "components/MainContent";
import TeamsDropdown from "components/TeamsDropdown";
import useTeamIdParam from "hooks/useTeamIdParam";
import RevealButton from "components/buttons/RevealButton";
import QueriesTable from "./components/QueriesTable";
import DeleteQueryModal from "./components/DeleteQueryModal";
import ManageAutomationsModal from "./components/ManageAutomationsModal";
const baseClass = "manage-queries-page";
interface IManageQueriesPageProps {
route: RouteProps;
router: InjectedRouter; // v3
location: {
pathname?: string;
pathname: string;
query: {
platform?: string;
page?: string;
query?: string;
order_key?: string;
order_direction?: "asc" | "desc";
team_id?: string;
};
search: string;
};
}
interface IQueryTableData extends IQuery {
interface IEnhancedQuery extends ISchedulableQuery {
performance: string;
platforms: string[];
}
@@ -54,7 +61,7 @@ const getPlatforms = (queryString: string): Array<IOsqueryPlatform | "---"> => {
return platforms || [DEFAULT_EMPTY_CELL_VALUE];
};
const enhanceQuery = (q: IQuery) => {
const enhanceQuery = (q: ISchedulableQuery): IEnhancedQuery => {
return {
...q,
performance: performanceIndicator(
@@ -71,51 +78,74 @@ const ManageQueriesPage = ({
const queryParams = location.query;
const {
isGlobalAdmin,
isTeamAdmin,
isOnlyObserver,
isObserverPlus,
isAnyTeamObserverPlus,
isOnGlobalTeam,
setFilteredQueriesPath,
filteredQueriesPath,
isPremiumTier,
isSandboxMode,
} = useContext(AppContext);
const { setResetSelectedRows } = useContext(TableContext);
const { renderFlash } = useContext(NotificationContext);
const [queriesList, setQueriesList] = useState<IQueryTableData[] | null>(
null
);
const {
userTeams,
currentTeamId,
handleTeamChange,
teamIdForApi,
isRouteOk,
} = useTeamIdParam({
location,
router,
includeAllTeams: true,
includeNoTeam: false,
});
const isAnyTeamSelected = currentTeamId !== -1;
const [selectedQueryIds, setSelectedQueryIds] = useState<number[]>([]);
const [showDeleteQueryModal, setShowDeleteQueryModal] = useState(false);
const [isUpdatingQueries, setIsUpdatingQueries] = useState(false);
const [showManageAutomationsModal, setShowManageAutomationsModal] = useState(
false
);
const [showInheritedQueries, setShowInheritedQueries] = useState(false);
const {
data: fleetQueries,
error: fleetQueriesError,
isFetching: isFetchingFleetQueries,
refetch: refetchFleetQueries,
} = useQuery<IFleetQueriesResponse, Error, IQuery[]>(
"fleet queries by platform",
() => fleetQueriesAPI.loadAll(),
data: curTeamEnhancedQueries,
error: curTeamQueriesError,
isFetching: isFetchingCurTeamQueries,
refetch: refetchCurTeamQueries,
} = useQuery<IListQueriesResponse, Error, IEnhancedQuery[]>(
[{ scope: "queries", teamId: teamIdForApi }],
() => queriesAPI.loadAll(teamIdForApi),
{
refetchOnWindowFocus: false,
select: (data: IFleetQueriesResponse) => data.queries,
enabled: isRouteOk,
select: (data) => data.queries.map(enhanceQuery),
}
);
const enhancedQueriesList = useMemo(() => {
const enhancedQueries = fleetQueries?.map((q: IQuery) => {
const query = enhanceQuery(q);
return query;
});
return enhancedQueries || [];
}, [fleetQueries]);
useEffect(() => {
if (!isFetchingFleetQueries && enhancedQueriesList) {
setQueriesList(enhancedQueriesList);
// If a team is selected, fetch inherited global queries as well
const {
data: globalEnhancedQueries,
error: globalQueriesError,
isFetching: isFetchingGlobalQueries,
refetch: refetchGlobalQueries,
} = useQuery<IListQueriesResponse, Error, IEnhancedQuery[]>(
[{ scope: "queries", teamId: -1 }],
() => queriesAPI.loadAll(),
{
refetchOnWindowFocus: false,
enabled: isRouteOk && isAnyTeamSelected,
select: (data) => data.queries.map(enhanceQuery),
}
}, [enhancedQueriesList, isFetchingFleetQueries]);
);
useEffect(() => {
const path = location.pathname + location.search;
@@ -130,85 +160,152 @@ const ManageQueriesPage = ({
setShowDeleteQueryModal(!showDeleteQueryModal);
}, [showDeleteQueryModal, setShowDeleteQueryModal]);
const toggleManageAutomationsModal = useCallback(() => {
setShowManageAutomationsModal(!showManageAutomationsModal);
}, [showManageAutomationsModal, setShowManageAutomationsModal]);
const onDeleteQueryClick = (selectedTableQueryIds: number[]) => {
toggleDeleteQueryModal();
setSelectedQueryIds(selectedTableQueryIds);
};
const onDeleteQuerySubmit = useCallback(async () => {
const queryOrQueries = selectedQueryIds.length === 1 ? "query" : "queries";
const refetchAllQueries = useCallback(() => {
refetchCurTeamQueries();
refetchGlobalQueries();
}, [refetchCurTeamQueries, refetchGlobalQueries]);
const onDeleteQuerySubmit = useCallback(async () => {
const bulk = selectedQueryIds.length > 1;
setIsUpdatingQueries(true);
const deleteQueries = selectedQueryIds.map((id) =>
fleetQueriesAPI.destroy(id)
);
try {
await Promise.all(deleteQueries).then(() => {
renderFlash("success", `Successfully deleted ${queryOrQueries}.`);
setResetSelectedRows(true);
refetchFleetQueries();
});
renderFlash("success", `Successfully deleted ${queryOrQueries}.`);
if (bulk) {
await queriesAPI.bulkDestroy(selectedQueryIds);
} else {
await queriesAPI.destroy(selectedQueryIds[0]);
}
renderFlash(
"success",
`Successfully deleted ${bulk ? "queries" : "query"}.`
);
setResetSelectedRows(true);
refetchAllQueries();
} catch (errorResponse) {
renderFlash(
"error",
`There was an error deleting your ${queryOrQueries}. Please try again later.`
`There was an error deleting your ${
bulk ? "queries" : "query"
}. Please try again later.`
);
} finally {
toggleDeleteQueryModal();
setIsUpdatingQueries(false);
}
}, [refetchFleetQueries, selectedQueryIds, toggleDeleteQueryModal]);
}, [refetchAllQueries, selectedQueryIds, toggleDeleteQueryModal]);
const isTableDataLoading = isFetchingFleetQueries || queriesList === null;
return (
<MainContent className={baseClass}>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__header-wrap`}>
<div className={`${baseClass}__header`}>
<div className={`${baseClass}__text`}>
<h1 className={`${baseClass}__title`}>
<span>Queries</span>
</h1>
</div>
</div>
{(!isOnlyObserver || isObserverPlus || isAnyTeamObserverPlus) &&
!!fleetQueries?.length && (
<div className={`${baseClass}__action-button-container`}>
<Button
variant="brand"
className={`${baseClass}__create-button`}
onClick={onCreateQueryClick}
>
Create new query
</Button>
</div>
)}
</div>
<div className={`${baseClass}__description`}>
<p>Manage queries to ask specific questions about your devices.</p>
</div>
<div>
{isTableDataLoading && !fleetQueriesError && <Spinner />}
{!isTableDataLoading && fleetQueriesError ? (
<TableDataError />
) : (
<QueriesTable
queriesList={queriesList}
isLoading={isTableDataLoading}
onCreateQueryClick={onCreateQueryClick}
onDeleteQueryClick={onDeleteQueryClick}
isOnlyObserver={isOnlyObserver}
isObserverPlus={isObserverPlus}
isAnyTeamObserverPlus={isAnyTeamObserverPlus || false}
router={router}
queryParams={queryParams}
const renderHeader = () => {
if (isPremiumTier) {
if (userTeams) {
if (userTeams.length > 1 || isOnGlobalTeam) {
return (
<TeamsDropdown
currentUserTeams={userTeams}
selectedTeamId={currentTeamId}
onChange={handleTeamChange}
isSandboxMode={isSandboxMode}
/>
)}
</div>
);
} else if (!isOnGlobalTeam && userTeams.length === 1) {
return <h1>{userTeams[0].name}</h1>;
}
}
}
return <h1>Queries</h1>;
};
const renderCurrentScopeQueriesTable = () => {
if (isFetchingCurTeamQueries) {
return <Spinner />;
}
if (curTeamQueriesError) {
return <TableDataError />;
}
return (
<div>
<QueriesTable
queriesList={curTeamEnhancedQueries || []}
isLoading={isFetchingCurTeamQueries}
onCreateQueryClick={onCreateQueryClick}
onDeleteQueryClick={onDeleteQueryClick}
isOnlyObserver={isOnlyObserver}
isObserverPlus={isObserverPlus}
isAnyTeamObserverPlus={isAnyTeamObserverPlus || false}
router={router}
queryParams={queryParams}
/>
</div>
);
};
const renderShowInheritedQueriesTableButton = () => {
const inheritedQueryCount = globalEnhancedQueries?.length;
return (
<RevealButton
isShowing={showInheritedQueries}
className={baseClass}
hideText={`Hide ${inheritedQueryCount} inherited quer${
inheritedQueryCount === 1 ? "y" : "ies"
}`}
showText={`Show ${inheritedQueryCount} inherited quer${
inheritedQueryCount === 1 ? "y" : "ies"
}`}
caretPosition={"before"}
tooltipHtml={
'Queries from the "All teams"<br/>schedule run on this teams hosts.'
}
onClick={() => {
setShowInheritedQueries(!showInheritedQueries);
}}
/>
);
};
const renderInheritedQueriesTable = () => {
if (isFetchingGlobalQueries) {
return <Spinner />;
}
if (globalQueriesError) {
return <TableDataError />;
}
return (
<div>
<QueriesTable
queriesList={globalEnhancedQueries || []}
isLoading={isFetchingGlobalQueries}
onCreateQueryClick={onCreateQueryClick}
onDeleteQueryClick={onDeleteQueryClick}
isOnlyObserver={isOnlyObserver}
isObserverPlus={isObserverPlus}
isAnyTeamObserverPlus={isAnyTeamObserverPlus || false}
router={router}
queryParams={queryParams}
/>
</div>
);
};
const renderInheritedQueriesSection = () => {
return (
<>
{renderShowInheritedQueriesTableButton()}
{showInheritedQueries && renderInheritedQueriesTable()}
</>
);
};
const renderModals = () => {
return (
<>
{showDeleteQueryModal && (
<DeleteQueryModal
isUpdatingQueries={isUpdatingQueries}
@@ -216,6 +313,56 @@ const ManageQueriesPage = ({
onSubmit={onDeleteQuerySubmit}
/>
)}
{showManageAutomationsModal && (
<ManageAutomationsModal onExit={toggleManageAutomationsModal} />
)}
</>
);
};
return (
<MainContent className={baseClass}>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__header-wrap`}>
<div className={`${baseClass}__header`}>
<div className={`${baseClass}__text`}>
<div className={`${baseClass}__title`}>{renderHeader()}</div>
</div>
</div>
<div className={`${baseClass}__action-button-container`}>
{(isGlobalAdmin || isTeamAdmin) && (
<Button
onClick={toggleManageAutomationsModal}
className={`${baseClass}__manage-automations button`}
variant="inverse"
>
Manage automations
</Button>
)}
{(!isOnlyObserver || isObserverPlus || isAnyTeamObserverPlus) &&
!!curTeamEnhancedQueries?.length && (
<Button
variant="brand"
className={`${baseClass}__create-button`}
onClick={onCreateQueryClick}
>
Add query
</Button>
)}
</div>
</div>
<div className={`${baseClass}__description`}>
<p>
Manage and schedule queries to ask questions and collect telemetry
for all hosts{isAnyTeamSelected && " assigned to this team"}.
</p>
</div>
{renderCurrentScopeQueriesTable()}
{isAnyTeamSelected &&
globalEnhancedQueries &&
globalEnhancedQueries?.length > 0 &&
renderInheritedQueriesSection()}
{renderModals()}
</div>
</MainContent>
);
@@ -56,7 +56,7 @@
&__action-button-container {
display: flex;
align-items: flex-start;
gap: $pad-small;
}
.form-field--dropdown {
@@ -103,11 +103,11 @@
.platforms__header {
width: $col-sm;
}
.author_name__header {
.updated_at__header {
display: none;
width: 0;
}
.updated_at__header {
.performance__header {
display: none;
width: 0;
}
@@ -151,28 +151,16 @@
.platforms__cell {
max-width: $col-md;
}
.author_name__cell {
display: none;
max-width: $col-md;
img,
div,
span {
display: flex;
align-items: center;
}
div {
padding-right: $pad-small;
}
.author-name {
display: block;
}
}
.updated_at__cell {
display: none;
max-width: $col-md;
}
.performance__cell {
display: none;
max-width: $col-md;
}
@media (min-width: $break-md) {
.author_name__cell {
.performance__cell {
display: table-cell;
}
}
@@ -0,0 +1,19 @@
import React from "react";
import Modal from "components/Modal";
const baseClass = "automations-modal";
interface IAutomationsModalProps {
onExit: () => void;
}
const AutomationsModal = ({ onExit }: IAutomationsModalProps): JSX.Element => {
return (
<Modal title={"Manage automations"} onExit={onExit} className={baseClass}>
<div className={baseClass} />
</Modal>
);
};
export default AutomationsModal;
@@ -0,0 +1,21 @@
import React from "react";
import Modal from "components/Modal";
const baseClass = "automations-modal";
interface IManageAutomationsModalProps {
onExit: () => void;
}
const ManageAutomationsModal = ({
onExit,
}: IManageAutomationsModalProps): JSX.Element => {
return (
<Modal title={"Manage automations"} onExit={onExit} className={baseClass}>
<div className={baseClass} />
</Modal>
);
};
export default ManageAutomationsModal;
@@ -0,0 +1 @@
export { default } from "./ManageAutomationsModal";
@@ -7,12 +7,11 @@ import formatDistanceToNow from "date-fns/formatDistanceToNow";
import PATHS from "router/paths";
import permissionsUtils from "utilities/permissions";
import { IQuery } from "interfaces/query";
import { IUser } from "interfaces/user";
import { addGravatarUrlToResource } from "utilities/helpers";
import { secondsToDhms } from "utilities/helpers";
import { ISchedulableQuery } from "interfaces/schedulable_query";
import Icon from "components/Icon";
import Avatar from "components/Avatar";
import Checkbox from "components/forms/fields/Checkbox";
import LinkCell from "components/TableContainer/DataTable/LinkCell/LinkCell";
import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCell";
@@ -20,10 +19,11 @@ import PlatformCell from "components/TableContainer/DataTable/PlatformCell";
import TextCell from "components/TableContainer/DataTable/TextCell";
import PillCell from "components/TableContainer/DataTable/PillCell";
import TooltipWrapper from "components/TooltipWrapper";
import StatusIndicator from "components/StatusIndicator";
interface IQueryRow {
id: string;
original: IQuery;
original: ISchedulableQuery;
}
interface IGetToggleAllRowsSelectedProps {
@@ -46,7 +46,7 @@ interface IHeaderProps {
}
interface IRowProps {
row: {
original: IQuery;
original: ISchedulableQuery;
getToggleRowSelectedProps: () => IGetToggleAllRowsSelectedProps;
toggleRowSelected: () => void;
};
@@ -54,6 +54,18 @@ interface IRowProps {
}
interface ICellProps extends IRowProps {
cell: {
value: string | number | boolean;
};
}
interface INumberCellProps extends IRowProps {
cell: {
value: number;
};
}
interface IStringCellProps extends IRowProps {
cell: {
value: string;
};
@@ -69,7 +81,9 @@ interface IDataColumn {
Header: ((props: IHeaderProps) => JSX.Element) | string;
Cell:
| ((props: ICellProps) => JSX.Element)
| ((props: IPlatformCellProps) => JSX.Element);
| ((props: IPlatformCellProps) => JSX.Element)
| ((props: IStringCellProps) => JSX.Element)
| ((props: INumberCellProps) => JSX.Element);
id?: string;
title?: string;
accessor?: string;
@@ -148,28 +162,26 @@ const generateTableHeaders = ({
},
},
{
title: "Author",
Header: (cellProps) => (
<HeaderCell
value={cellProps.column.title}
isSortedDesc={cellProps.column.isSortedDesc}
/>
),
accessor: "author_name",
Cell: (cellProps: ICellProps): JSX.Element => {
const { author_name, author_email } = cellProps.row.original;
const author = author_name === currentUser.name ? "You" : author_name;
title: "Frequency",
Header: "Frequency",
disableSortBy: true,
accessor: "interval",
Cell: (cellProps: INumberCellProps): JSX.Element => {
const val = cellProps.cell.value
? `Every ${secondsToDhms(cellProps.cell.value)}`
: undefined;
return (
<span>
<Avatar
user={addGravatarUrlToResource({ email: author_email })}
size="xsmall"
/>
<span className="text-cell author-name">{author}</span>
</span>
<TextCell
value={val}
emptyCellTooltipText={
<>
Assign a frequency and turn <strong>automations</strong> on to
collect data at an interval.
</>
}
/>
);
},
sortType: "caseInsensitive",
},
{
Header: () => {
@@ -189,7 +201,7 @@ const generateTableHeaders = ({
},
disableSortBy: true,
accessor: "performance",
Cell: (cellProps: ICellProps) => (
Cell: (cellProps: IStringCellProps) => (
<PillCell
value={{
indicator: cellProps.cell.value,
@@ -198,6 +210,38 @@ const generateTableHeaders = ({
/>
),
},
{
title: "Automations",
Header: "Automations",
disableSortBy: true,
accessor: "automations_enabled",
Cell: (cellProps: IStringCellProps): JSX.Element => {
let status;
if (cellProps.cell.value) {
if (cellProps.row.original.interval === 0) {
status = "paused";
} else {
status = "on";
}
} else {
status = "off";
}
const tooltip =
status === "paused"
? {
id: cellProps.row.original.id,
tooltipText: (
<>
<strong>Automations</strong> will resume for this query when
a frequency is set.
</>
),
}
: undefined;
return <StatusIndicator value={status} tooltip={tooltip} />;
},
},
{
title: "Last modified",
Header: (cellProps) => (
@@ -207,7 +251,7 @@ const generateTableHeaders = ({
/>
),
accessor: "updated_at",
Cell: (cellProps: ICellProps): JSX.Element => (
Cell: (cellProps: INumberCellProps): JSX.Element => (
<TextCell
value={formatDistanceToNow(new Date(cellProps.cell.value), {
includeSeconds: true,
@@ -1,546 +0,0 @@
/* Conditionally renders global schedule and team schedules */
import React, { useCallback, useContext, useState } from "react";
import { useQuery } from "react-query";
import { InjectedRouter } from "react-router/lib/Router";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import useTeamIdParam from "hooks/useTeamIdParam";
import { ITeam } from "interfaces/team";
import { IQuery, IFleetQueriesResponse } from "interfaces/query";
import {
IScheduledQuery,
IEditScheduledQuery,
ILoadAllGlobalScheduledQueriesResponse,
IStoredScheduledQueriesResponse,
} from "interfaces/scheduled_query";
import paths from "router/paths";
import fleetQueriesAPI from "services/entities/queries";
import globalScheduledQueriesAPI from "services/entities/global_scheduled_queries";
import teamScheduledQueriesAPI from "services/entities/team_scheduled_queries";
import teamsAPI, { ILoadTeamsResponse } from "services/entities/teams";
import deepDifference from "utilities/deep_difference";
import Button from "components/buttons/Button";
import RevealButton from "components/buttons/RevealButton";
import Spinner from "components/Spinner";
import TeamsDropdown from "components/TeamsDropdown";
import TableDataError from "components/DataError";
import MainContent from "components/MainContent";
import ShowQueryModal from "components/modals/ShowQueryModal";
import ScheduleTable from "./components/ScheduleTable";
import ScheduleEditorModal from "./components/ScheduleEditorModal";
import RemoveScheduledQueryModal from "./components/RemoveScheduledQueryModal";
const baseClass = "manage-schedule-page";
const renderTable = (
router: InjectedRouter,
onRemoveScheduledQueryClick: (selectIds: number[]) => void,
onEditScheduledQueryClick: (selectedQuery: IEditScheduledQuery) => void,
onShowQueryClick: (selectedQuery: IEditScheduledQuery) => void,
allScheduledQueriesList: IScheduledQuery[],
allScheduledQueriesError: Error | null,
toggleScheduleEditorModal: () => void,
isOnGlobalTeam: boolean,
selectedTeamData: ITeam | undefined,
isLoadingGlobalScheduledQueries: boolean,
isLoadingTeamScheduledQueries: boolean,
errorQueries: Error | null
): JSX.Element => {
return allScheduledQueriesError || errorQueries ? (
<TableDataError />
) : (
<ScheduleTable
router={router}
onRemoveScheduledQueryClick={onRemoveScheduledQueryClick}
onEditScheduledQueryClick={onEditScheduledQueryClick}
onShowQueryClick={onShowQueryClick}
allScheduledQueriesList={allScheduledQueriesList}
toggleScheduleEditorModal={toggleScheduleEditorModal}
isOnGlobalTeam={isOnGlobalTeam}
selectedTeamData={selectedTeamData}
loadingInheritedQueriesTableData={isLoadingGlobalScheduledQueries}
loadingTeamQueriesTableData={isLoadingTeamScheduledQueries}
/>
);
};
const renderAllTeamsTable = (
router: InjectedRouter,
allTeamsScheduledQueriesList: IScheduledQuery[],
allTeamsScheduledQueriesError: Error | null,
isOnGlobalTeam: boolean,
selectedTeamData: ITeam | undefined,
isLoadingGlobalScheduledQueries: boolean,
isLoadingTeamScheduledQueries: boolean
): JSX.Element => {
return allTeamsScheduledQueriesError ? (
<TableDataError />
) : (
<div className={`${baseClass}__all-teams-table`}>
<ScheduleTable
router={router}
inheritedQueries
allScheduledQueriesList={allTeamsScheduledQueriesList}
isOnGlobalTeam={isOnGlobalTeam}
selectedTeamData={selectedTeamData}
loadingInheritedQueriesTableData={isLoadingGlobalScheduledQueries}
loadingTeamQueriesTableData={isLoadingTeamScheduledQueries}
/>
</div>
);
};
interface IFormData {
interval: number;
name?: string;
shard: number;
query?: string;
query_id?: number;
logging_type: string;
platform: string;
version: string;
team_id?: number;
}
interface ITeamSchedulesPageProps {
params: {
team_id: string;
};
router: InjectedRouter; // v3
route: any;
location: any;
}
const ManageSchedulePage = ({
router,
location,
}: ITeamSchedulesPageProps): JSX.Element => {
const { renderFlash } = useContext(NotificationContext);
const { MANAGE_PACKS } = paths;
const handleAdvanced = () => router.push(MANAGE_PACKS);
const {
isOnGlobalTeam,
isPremiumTier,
isFreeTier,
isSandboxMode,
} = useContext(AppContext);
const {
currentTeamId,
isAnyTeamSelected,
isRouteOk,
teamIdForApi,
userTeams,
handleTeamChange,
} = useTeamIdParam({
location,
router,
includeAllTeams: true,
includeNoTeam: false,
permittedAccessByTeamRole: {
admin: true,
maintainer: true,
observer: false,
observer_plus: false,
},
});
const { data: teams, isLoading: isLoadingTeams } = useQuery<
ILoadTeamsResponse,
Error,
ITeam[]
>(["teams"], () => teamsAPI.loadAll(), {
enabled: isRouteOk && !!isPremiumTier,
refetchOnMount: false,
refetchOnWindowFocus: false,
select: (data) => data.teams,
});
const {
data: fleetQueries,
isLoading: isLoadingFleetQueries,
error: errorQueries,
} = useQuery<IFleetQueriesResponse, Error, IQuery[]>(
["fleetQueries"],
() => fleetQueriesAPI.loadAll(),
{
enabled: isRouteOk,
refetchOnMount: false,
refetchOnWindowFocus: false,
select: (data) => data.queries,
}
);
const {
data: globalScheduledQueries,
error: globalScheduledQueriesError,
isLoading: isLoadingGlobalScheduledQueries,
refetch: refetchGlobalScheduledQueries,
} = useQuery<
ILoadAllGlobalScheduledQueriesResponse,
Error,
IScheduledQuery[]
>(["globalScheduledQueries"], () => globalScheduledQueriesAPI.loadAll(), {
enabled: isRouteOk,
select: (data) => data.global_schedule,
});
const {
data: teamScheduledQueries,
error: teamScheduledQueriesError,
isLoading: isLoadingTeamScheduledQueries,
refetch: refetchTeamScheduledQueries,
} = useQuery<IStoredScheduledQueriesResponse, Error, IScheduledQuery[]>(
["teamScheduledQueries", teamIdForApi],
() => teamScheduledQueriesAPI.loadAll(teamIdForApi),
{
enabled: isRouteOk && isPremiumTier && !!teamIdForApi,
select: (data) => data.scheduled,
}
);
const refetchScheduledQueries = useCallback(() => {
refetchGlobalScheduledQueries();
if (isAnyTeamSelected) {
refetchTeamScheduledQueries();
}
}, [
isAnyTeamSelected,
refetchGlobalScheduledQueries,
refetchTeamScheduledQueries,
]);
const allScheduledQueriesList =
(isAnyTeamSelected ? teamScheduledQueries : globalScheduledQueries) || [];
const allScheduledQueriesError = isAnyTeamSelected
? teamScheduledQueriesError
: globalScheduledQueriesError;
const inheritedScheduledQueriesList = globalScheduledQueries;
const inheritedScheduledQueriesError = globalScheduledQueriesError;
const inheritedQueryOrQueries =
inheritedScheduledQueriesList?.length === 1 ? "query" : "queries";
const selectedTeamData = isAnyTeamSelected
? teams?.find((team: ITeam) => teamIdForApi === team.id)
: undefined;
const [isUpdatingScheduledQuery, setIsUpdatingScheduledQuery] = useState(
false
);
const [showInheritedQueries, setShowInheritedQueries] = useState(false);
const [showScheduleEditorModal, setShowScheduleEditorModal] = useState(false);
const [showShowQueryModal, setShowShowQueryModal] = useState(false);
const [showPreviewDataModal, setShowPreviewDataModal] = useState(false);
const [
showRemoveScheduledQueryModal,
setShowRemoveScheduledQueryModal,
] = useState(false);
const [selectedQueryIds, setSelectedQueryIds] = useState<number[] | never[]>(
[]
);
const [
selectedScheduledQuery,
setSelectedScheduledQuery,
] = useState<IEditScheduledQuery>();
const toggleInheritedQueries = () => {
setShowInheritedQueries(!showInheritedQueries);
};
const togglePreviewDataModal = useCallback(() => {
setShowPreviewDataModal(!showPreviewDataModal);
}, [setShowPreviewDataModal, showPreviewDataModal]);
const toggleScheduleEditorModal = useCallback(() => {
setSelectedScheduledQuery(undefined); // create modal renders
setShowScheduleEditorModal(!showScheduleEditorModal);
}, [showScheduleEditorModal, setShowScheduleEditorModal]);
const toggleShowQueryModal = useCallback(() => {
setSelectedScheduledQuery(undefined);
setShowShowQueryModal(!showShowQueryModal);
}, [showShowQueryModal, setShowShowQueryModal]);
const toggleRemoveScheduledQueryModal = useCallback(() => {
setShowRemoveScheduledQueryModal(!showRemoveScheduledQueryModal);
}, [showRemoveScheduledQueryModal, setShowRemoveScheduledQueryModal]);
const onRemoveScheduledQueryClick = (
selectedTableQueryIds: number[]
): void => {
toggleRemoveScheduledQueryModal();
setSelectedQueryIds(selectedTableQueryIds);
};
const onShowQueryClick = (selectedQuery: IEditScheduledQuery): void => {
toggleShowQueryModal();
setSelectedScheduledQuery(selectedQuery);
};
const onEditScheduledQueryClick = (
selectedQuery: IEditScheduledQuery
): void => {
toggleScheduleEditorModal();
setSelectedScheduledQuery(selectedQuery); // edit modal renders
};
const onRemoveScheduledQuerySubmit = useCallback(() => {
setIsUpdatingScheduledQuery(true);
const promises = selectedQueryIds.map((id: number) => {
return isAnyTeamSelected
? teamScheduledQueriesAPI.destroy(teamIdForApi, id)
: globalScheduledQueriesAPI.destroy({ id });
});
const queryOrQueries = selectedQueryIds.length === 1 ? "query" : "queries";
return Promise.all(promises)
.then(() => {
renderFlash(
"success",
`Successfully removed scheduled ${queryOrQueries}.`
);
toggleRemoveScheduledQueryModal();
refetchScheduledQueries();
})
.catch(() => {
renderFlash(
"error",
`Unable to remove scheduled ${queryOrQueries}. Please try again.`
);
toggleRemoveScheduledQueryModal();
})
.finally(() => {
refetchGlobalScheduledQueries();
setIsUpdatingScheduledQuery(false);
});
}, [
selectedQueryIds,
isAnyTeamSelected,
teamIdForApi,
renderFlash,
toggleRemoveScheduledQueryModal,
refetchScheduledQueries,
refetchGlobalScheduledQueries,
]);
const onAddScheduledQuerySubmit = useCallback(
(formData: IFormData, editQuery: IEditScheduledQuery | undefined) => {
setIsUpdatingScheduledQuery(true);
if (editQuery) {
const updatedAttributes = deepDifference(formData, editQuery);
const editResponse =
editQuery.type === "team_scheduled_query"
? teamScheduledQueriesAPI.update(editQuery, updatedAttributes)
: globalScheduledQueriesAPI.update(editQuery, updatedAttributes);
editResponse
.then(() => {
renderFlash(
"success",
`Successfully updated ${formData.name} in the schedule.`
);
refetchScheduledQueries();
toggleScheduleEditorModal();
})
.catch(() => {
renderFlash(
"error",
"Could not update scheduled query. Please try again."
);
})
.finally(() => {
setIsUpdatingScheduledQuery(false);
refetchGlobalScheduledQueries();
});
} else {
const createResponse = isAnyTeamSelected
? teamScheduledQueriesAPI.create({ ...formData })
: globalScheduledQueriesAPI.create({ ...formData });
createResponse
.then(() => {
renderFlash(
"success",
`Successfully added ${formData.name} to the schedule.`
);
refetchScheduledQueries();
toggleScheduleEditorModal();
})
.catch(() => {
renderFlash("error", "Could not schedule query. Please try again.");
})
.finally(() => {
setIsUpdatingScheduledQuery(false);
refetchGlobalScheduledQueries();
});
}
},
[
isAnyTeamSelected,
refetchGlobalScheduledQueries,
refetchScheduledQueries,
renderFlash,
toggleScheduleEditorModal,
]
);
if (!isRouteOk || (isPremiumTier && !userTeams?.length)) {
return (
<div className={`${baseClass}__loading-spinner`}>
<Spinner />
</div>
);
}
return (
<MainContent className={baseClass}>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__header-wrap`}>
<div className={`${baseClass}__header`}>
<div className={`${baseClass}__text`}>
<div className={`${baseClass}__title`}>
{isFreeTier && <h1>Schedule</h1>}
{isPremiumTier &&
userTeams &&
(userTeams.length > 1 || isOnGlobalTeam) && (
<TeamsDropdown
selectedTeamId={currentTeamId}
currentUserTeams={userTeams || []}
onChange={handleTeamChange}
isSandboxMode={isSandboxMode}
/>
)}
{isPremiumTier &&
!isOnGlobalTeam &&
userTeams &&
userTeams.length === 1 && <h1>{userTeams[0].name}</h1>}
</div>
</div>
</div>
{allScheduledQueriesList?.length !== 0 && !allScheduledQueriesError && (
<div className={`${baseClass}__action-button-container`}>
{/* NOTE: Product decision to remove packs from UI
{isOnGlobalTeam && (
<Button
variant="inverse"
onClick={handleAdvanced}
className={`${baseClass}__advanced-button`}
>
Advanced
</Button>
)} */}
<Button
variant="brand"
className={`${baseClass}__schedule-button`}
onClick={toggleScheduleEditorModal}
>
Schedule a query
</Button>
</div>
)}
</div>
<div className={`${baseClass}__description`}>
{!isLoadingTeams && (
<div>
{isAnyTeamSelected ? (
<p>
Schedule queries for{" "}
<strong>all hosts assigned to this team</strong>
</p>
) : (
<p>
Schedule queries to run at regular intervals across{" "}
<strong>all of your hosts</strong>
</p>
)}
</div>
)}
</div>
<div>
{isLoadingTeams ||
isLoadingFleetQueries ||
isLoadingGlobalScheduledQueries ||
isLoadingTeamScheduledQueries ? (
<Spinner />
) : (
renderTable(
router,
onRemoveScheduledQueryClick,
onEditScheduledQueryClick,
onShowQueryClick,
allScheduledQueriesList,
allScheduledQueriesError,
toggleScheduleEditorModal,
isOnGlobalTeam || false,
selectedTeamData,
isLoadingGlobalScheduledQueries,
isLoadingTeamScheduledQueries,
errorQueries
)
)}
</div>
{/* must use ternary for NaN */}
{isAnyTeamSelected &&
inheritedScheduledQueriesList &&
inheritedScheduledQueriesList.length > 0 ? (
<RevealButton
isShowing={showInheritedQueries}
className={baseClass}
hideText={`Hide ${inheritedScheduledQueriesList.length} inherited ${inheritedQueryOrQueries}`}
showText={`Show ${inheritedScheduledQueriesList.length} inherited ${inheritedQueryOrQueries}`}
caretPosition={"before"}
tooltipHtml={
'Queries from the "All teams"<br/>schedule run on this teams hosts.'
}
onClick={toggleInheritedQueries}
/>
) : null}
{showInheritedQueries &&
inheritedScheduledQueriesList &&
renderAllTeamsTable(
router,
inheritedScheduledQueriesList,
inheritedScheduledQueriesError,
isOnGlobalTeam || false,
selectedTeamData,
isLoadingGlobalScheduledQueries,
isLoadingTeamScheduledQueries
)}
{showScheduleEditorModal && fleetQueries && (
<ScheduleEditorModal
onClose={toggleScheduleEditorModal}
onScheduleSubmit={onAddScheduledQuerySubmit}
allQueries={fleetQueries}
editQuery={selectedScheduledQuery}
teamId={teamIdForApi}
togglePreviewDataModal={togglePreviewDataModal}
showPreviewDataModal={showPreviewDataModal}
isUpdatingScheduledQuery={isUpdatingScheduledQuery}
/>
)}
{showRemoveScheduledQueryModal && (
<RemoveScheduledQueryModal
onCancel={toggleRemoveScheduledQueryModal}
onSubmit={onRemoveScheduledQuerySubmit}
isUpdatingScheduledQuery={isUpdatingScheduledQuery}
/>
)}
{showShowQueryModal && (
<ShowQueryModal
query={selectedScheduledQuery?.query}
onCancel={toggleShowQueryModal}
/>
)}
</div>
</MainContent>
);
};
export default ManageSchedulePage;
@@ -1,122 +0,0 @@
.manage-schedule-page {
&__header-wrap {
display: flex;
align-items: center;
justify-content: space-between;
height: 38px;
}
&__header {
display: flex;
align-items: center;
.form-field {
margin-bottom: 0;
}
}
&__text {
margin-right: $pad-large;
}
&__title {
font-size: $large;
.fleeticon {
color: $core-fleet-blue;
margin-right: 15px;
}
.fleeticon-success-check {
color: $ui-success;
}
.fleeticon-offline {
color: $ui-error;
}
}
&__description {
margin: 0;
margin-bottom: $pad-xxlarge;
h2 {
text-transform: uppercase;
color: $core-fleet-black;
font-weight: $regular;
font-size: $small;
}
p {
color: $ui-fleet-black-75;
margin: 0;
font-size: $x-small;
font-style: italic;
}
}
&__action-button-container {
display: flex;
align-items: flex-start;
}
&__advanced-button {
margin-right: $pad-medium;
}
.Select.is-open {
.Select-value-label {
color: $core-vibrant-blue !important;
}
}
.schedule-table {
.data-table-block {
.data-table__table {
thead {
.query_name__header {
width: $col-lg;
}
.interval__header {
width: auto;
}
.actions__header {
width: auto;
}
@media (min-width: $break-lg) {
.interval__header {
width: 0;
}
}
}
tbody {
.query_name__cell {
width: $col-lg;
max-width: 175px; // Truncates at smaller widths
}
.interval__cell {
width: auto;
}
.actions__cell {
width: auto;
}
@media (min-width: $break-lg) {
.interval_cell {
width: 0;
}
}
}
}
}
.empty-table__container {
max-width: 465px; // Fixes wider font causing orphaned word on all teams empty state
}
}
.no-team-schedule {
border: 1px solid #e2e4ea;
box-sizing: border-box;
border-radius: 8px;
}
}
@@ -1,61 +0,0 @@
/* This component is used for creating and editing both global and team scheduled queries */
import React from "react";
import { syntaxHighlight } from "utilities/helpers";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
import TooltipWrapper from "components/TooltipWrapper";
const baseClass = "preview-data-modal";
interface IPreviewDataModalProps {
onCancel: () => void;
}
const PreviewDataModal = ({
onCancel,
}: IPreviewDataModalProps): JSX.Element => {
const json = {
action: "snapshot",
snapshot: [
{
remote_address: "0.0.0.0",
remote_port: "0",
cmdline: "/usr/sbin/syslogd",
},
],
name: "xxxxxxx",
hostIdentifier: "xxxxxxx",
calendarTime: "xxx xxx x xx:xx:xx xxxx UTC",
unixTime: "xxxxxxxxx",
epoch: "xxxxxxxxx",
counter: "x",
numerics: "x",
};
return (
<Modal title={"Example data"} onExit={onCancel} className={baseClass}>
<div className={`${baseClass}__preview-modal`}>
<p>
<TooltipWrapper
tipContent={`The &quot;snapshot&quot; key includes the query&apos;s results. These will be unique to your query.`}
>
The data sent to your configured log destination will look similar
to the following JSON:
</TooltipWrapper>
</p>
<div className={`${baseClass}__host-status-webhook-preview`}>
<pre dangerouslySetInnerHTML={{ __html: syntaxHighlight(json) }} />
</div>
<div className="modal-cta-wrap">
<Button onClick={onCancel} variant="brand">
Done
</Button>
</div>
</div>
</Modal>
);
};
export default PreviewDataModal;
@@ -1,14 +0,0 @@
.preview-data-modal {
&__sandbox-info {
margin-top: $pad-medium;
p {
margin: 0;
margin-bottom: $pad-medium;
}
p:last-child {
margin-bottom: 0;
}
}
}
@@ -1 +0,0 @@
export { default } from "./PreviewDataModal";
@@ -1,47 +0,0 @@
import React from "react";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
const baseClass = "remove-scheduled-query-modal";
interface IRemoveScheduledQueryModalProps {
isUpdatingScheduledQuery: boolean;
onCancel: () => void;
onSubmit: () => void;
}
const RemoveScheduledQueryModal = ({
isUpdatingScheduledQuery,
onCancel,
onSubmit,
}: IRemoveScheduledQueryModalProps): JSX.Element => {
return (
<Modal
title={"Remove scheduled query"}
onExit={onCancel}
onEnter={onSubmit}
className={baseClass}
>
<div className={baseClass}>
Are you sure you want to remove the selected queries from the schedule?
<div className="modal-cta-wrap">
<Button
type="button"
variant="alert"
onClick={onSubmit}
className="remove-loading"
isLoading={isUpdatingScheduledQuery}
>
Remove
</Button>
<Button onClick={onCancel} variant="inverse-alert">
Cancel
</Button>
</div>
</div>
</Modal>
);
};
export default RemoveScheduledQueryModal;
@@ -1 +0,0 @@
export { default } from "./RemoveScheduledQueryModal";
@@ -1,364 +0,0 @@
/* This component is used for creating and editing both global and team scheduled queries */
import React, { useState, useCallback, useContext } from "react";
import { pull } from "lodash";
import { AppContext } from "context/app";
import { IQuery } from "interfaces/query";
import { IEditScheduledQuery } from "interfaces/scheduled_query";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
import RevealButton from "components/buttons/RevealButton";
import InfoBanner from "components/InfoBanner/InfoBanner";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
import CustomLink from "components/CustomLink";
import {
FREQUENCY_DROPDOWN_OPTIONS,
SCHEDULE_PLATFORM_DROPDOWN_OPTIONS,
LOGGING_TYPE_OPTIONS,
MIN_OSQUERY_VERSION_OPTIONS,
} from "utilities/constants";
import PreviewDataModal from "../PreviewDataModal";
const baseClass = "schedule-editor-modal";
interface IFormData {
interval: number;
name?: string;
shard: number;
query?: string;
query_id?: number;
logging_type: string;
platform: string;
version: string;
team_id?: number;
}
interface IScheduleEditorModalProps {
allQueries: IQuery[];
onClose: () => void;
onScheduleSubmit: (
formData: IFormData,
editQuery: IEditScheduledQuery | undefined
) => void;
editQuery?: IEditScheduledQuery;
teamId?: number;
togglePreviewDataModal: () => void;
showPreviewDataModal: boolean;
isUpdatingScheduledQuery: boolean;
}
interface INoQueryOption {
id: number;
name: string;
}
const generateLoggingType = (query: IEditScheduledQuery) => {
if (query.snapshot) {
return "snapshot";
}
if (query.removed) {
return "differential";
}
return "differential_ignore_removals";
};
const generateLoggingDestination = (loggingConfig: string): string => {
switch (loggingConfig) {
case "filesystem":
return "the filesystem";
case "firehose":
return "AWS Kinesis Firehose";
case "kinesis":
return "AWS Kinesis";
case "lambda":
return "AWS Lambda";
case "pubsub":
return "GCP PubSub";
case "stdout":
return "the standard output stream";
default:
return loggingConfig;
}
};
const ScheduleEditorModal = ({
onClose,
onScheduleSubmit,
allQueries,
editQuery,
teamId,
togglePreviewDataModal,
showPreviewDataModal,
isUpdatingScheduledQuery,
}: IScheduleEditorModalProps): JSX.Element => {
const { config } = useContext(AppContext);
const loggingConfig = config?.logging.result.plugin || "unknown";
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const [selectedQuery, setSelectedQuery] = useState<
IEditScheduledQuery | INoQueryOption
>();
const [selectedFrequency, setSelectedFrequency] = useState(
editQuery ? editQuery.interval : 86400
);
const [selectedPlatformOptions, setSelectedPlatformOptions] = useState(
editQuery?.platform || ""
);
const [selectedLoggingType, setSelectedLoggingType] = useState(
editQuery ? generateLoggingType(editQuery) : "snapshot"
);
const [
selectedMinOsqueryVersionOptions,
setSelectedMinOsqueryVersionOptions,
] = useState(editQuery?.version || "");
const [selectedShard, setSelectedShard] = useState(
editQuery?.shard ? editQuery?.shard.toString() : ""
);
const createQueryDropdownOptions = () => {
const queryOptions = allQueries.map((q) => {
return {
value: String(q.id),
label: q.name,
};
});
return queryOptions;
};
const toggleAdvancedOptions = () => {
setShowAdvancedOptions(!showAdvancedOptions);
};
const onChangeSelectQuery = useCallback(
(queryId: string) => {
const queryWithId: IQuery | undefined = allQueries.find(
(query: IQuery) => query.id === parseInt(queryId, 10)
);
setSelectedQuery(queryWithId);
},
[allQueries, setSelectedQuery]
);
const onChangeSelectFrequency = useCallback(
(value: number) => {
setSelectedFrequency(value);
},
[setSelectedFrequency]
);
const onChangeSelectPlatformOptions = useCallback(
(values: string) => {
const valArray = values.split(",");
// Remove All if another OS is chosen
// else if Remove OS if All is chosen
if (valArray.indexOf("") === 0 && valArray.length > 1) {
setSelectedPlatformOptions(pull(valArray, "").join(","));
} else if (valArray.length > 1 && valArray.indexOf("") > -1) {
setSelectedPlatformOptions("");
} else {
setSelectedPlatformOptions(values);
}
},
[setSelectedPlatformOptions]
);
const onChangeSelectLoggingType = useCallback(
(value: string) => {
setSelectedLoggingType(value);
},
[setSelectedLoggingType]
);
const onChangeMinOsqueryVersionOptions = useCallback(
(value: string) => {
setSelectedMinOsqueryVersionOptions(value);
},
[setSelectedMinOsqueryVersionOptions]
);
const onChangeShard = useCallback(
(value: string) => {
setSelectedShard(value);
},
[setSelectedShard]
);
const onFormSubmit = (): void => {
const query_id = () => {
if (editQuery) {
return editQuery.query_id;
}
return selectedQuery?.id;
};
const name = () => {
if (editQuery) {
return editQuery.name;
}
return selectedQuery?.name;
};
onScheduleSubmit(
{
shard: parseInt(selectedShard, 10),
interval: selectedFrequency,
query_id: query_id(),
name: name(),
logging_type: selectedLoggingType,
platform: selectedPlatformOptions,
version: selectedMinOsqueryVersionOptions,
team_id: teamId,
},
editQuery
);
};
if (showPreviewDataModal) {
return <PreviewDataModal onCancel={togglePreviewDataModal} />;
}
return (
<Modal
title={editQuery?.query_name || "Schedule editor"}
onExit={onClose}
onEnter={onFormSubmit}
className={baseClass}
width="large"
>
<form className={`${baseClass}__form`}>
<p className={`${baseClass}__platform-compatibility`}>
Scheduled queries can currently be run on macOS, Windows, and Linux
hosts. Interested in collecting data from your Chromebooks?{" "}
<CustomLink
url="https://www.fleetdm.com/contact"
text="Let us know"
newTab
/>
</p>
{!editQuery && (
<Dropdown
searchable
options={createQueryDropdownOptions()}
onChange={onChangeSelectQuery}
placeholder={"Select query"}
value={selectedQuery?.id}
wrapperClassName={`${baseClass}__select-query-dropdown-wrapper`}
autoFocus
/>
)}
<Dropdown
searchable={false}
options={FREQUENCY_DROPDOWN_OPTIONS}
onChange={onChangeSelectFrequency}
placeholder={"Every day"}
value={selectedFrequency}
label={"Choose a frequency and then run this query on a schedule"}
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--frequency`}
/>
<InfoBanner className={`${baseClass}__sandbox-info`}>
<p>
Your configured log destination is <b>{loggingConfig}</b>.
</p>
<p>
{loggingConfig === "unknown"
? ""
: `This means that when this query is run on your hosts, the data will
be sent to ${generateLoggingDestination(loggingConfig)}.`}
</p>
<p>
Check out the Fleet documentation on&nbsp;
<CustomLink
url="https://fleetdm.com/docs/deploying/configuration#osquery-result-log-plugin"
text="how to configure a different log destination"
newTab
multiline
/>
.
</p>
</InfoBanner>
<div>
<RevealButton
isShowing={showAdvancedOptions}
className={baseClass}
hideText={"Hide advanced options"}
showText={"Show advanced options"}
caretPosition={"after"}
onClick={toggleAdvancedOptions}
/>
{showAdvancedOptions && (
<div>
<Dropdown
options={LOGGING_TYPE_OPTIONS}
onChange={onChangeSelectLoggingType}
placeholder="Select"
value={selectedLoggingType}
label="Logging"
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--logging`}
/>
<Dropdown
options={SCHEDULE_PLATFORM_DROPDOWN_OPTIONS}
placeholder="Select"
label="Platform"
onChange={onChangeSelectPlatformOptions}
value={selectedPlatformOptions}
multi
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--platform`}
/>
<Dropdown
options={MIN_OSQUERY_VERSION_OPTIONS}
onChange={onChangeMinOsqueryVersionOptions}
placeholder="Select"
value={selectedMinOsqueryVersionOptions}
label="Minimum osquery version"
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--osquer-vers`}
/>
<InputField
onChange={onChangeShard}
inputWrapperClass={`${baseClass}__form-field ${baseClass}__form-field--shard`}
value={selectedShard}
placeholder="- - -"
label="Shard"
type="number"
/>
</div>
)}
</div>
<div className={`${baseClass}__btn-wrap`}>
<div className={`${baseClass}__preview-btn-wrap`}>
<Button
type="button"
variant="inverse"
onClick={togglePreviewDataModal}
>
Preview data
</Button>
</div>
<div className="modal-cta-wrap">
<Button
type="button"
variant="brand"
onClick={onFormSubmit}
disabled={!selectedQuery && !editQuery}
className="schedule-loading"
isLoading={isUpdatingScheduledQuery}
>
Schedule
</Button>
<Button onClick={onClose} variant="inverse">
Cancel
</Button>
</div>
</div>
</form>
</Modal>
);
};
export default ScheduleEditorModal;
@@ -1,26 +0,0 @@
.schedule-editor-modal {
&__platform-compatibility {
margin-bottom: $pad-large;
}
&__sandbox-info {
margin-top: $pad-medium;
p {
margin: 0;
margin-bottom: $pad-medium;
}
p:last-child {
margin-bottom: 0;
}
}
&__info-header {
font-weight: $bold;
}
.Select-value-label {
font-size: $small;
}
}
@@ -1 +0,0 @@
export { default } from "./ScheduleEditorModal";
@@ -1,224 +0,0 @@
/**
* Component when there is an error retrieving schedule set up in fleet
*/
import React from "react";
import { InjectedRouter } from "react-router";
import paths from "router/paths";
import {
IScheduledQuery,
IEditScheduledQuery,
} from "interfaces/scheduled_query";
import { ITeam } from "interfaces/team";
import { IEmptyTableProps } from "interfaces/empty_table";
import Button from "components/buttons/Button";
import CustomLink from "components/CustomLink";
import TableContainer from "components/TableContainer";
import EmptyTable from "components/EmptyTable";
import {
generateInheritedQueriesTableHeaders,
generateTableHeaders,
generateDataSet,
} from "./ScheduleTableConfig";
const baseClass = "schedule-table";
const TAGGED_TEMPLATES = {
hostsByTeamRoute: (teamId: number | undefined | null) => {
return `${teamId ? `/?team_id=${teamId}` : ""}`;
},
};
interface IScheduleTableProps {
router: InjectedRouter; // v3
onRemoveScheduledQueryClick?: (selectedIds: number[]) => void;
onEditScheduledQueryClick?: (selectedQuery: IEditScheduledQuery) => void;
onShowQueryClick?: (selectedQuery: IEditScheduledQuery) => void;
allScheduledQueriesList: IScheduledQuery[];
toggleScheduleEditorModal?: () => void;
inheritedQueries?: boolean;
isOnGlobalTeam: boolean;
selectedTeamData: ITeam | undefined;
loadingInheritedQueriesTableData: boolean;
loadingTeamQueriesTableData: boolean;
}
const ScheduleTable = ({
router,
onRemoveScheduledQueryClick,
onEditScheduledQueryClick,
onShowQueryClick,
allScheduledQueriesList,
toggleScheduleEditorModal,
inheritedQueries,
isOnGlobalTeam,
selectedTeamData,
loadingInheritedQueriesTableData,
loadingTeamQueriesTableData,
}: IScheduleTableProps): JSX.Element => {
const { MANAGE_PACKS, MANAGE_HOSTS } = paths;
const handleAdvanced = () => router.push(MANAGE_PACKS);
const emptyState = () => {
const emptySchedule: IEmptyTableProps = {
iconName: "empty-schedule",
header: (
<>
Schedule queries to run at regular intervals on{" "}
<a href={MANAGE_HOSTS}>all your hosts</a>
</>
),
additionalInfo: (
<>
Want to learn more?&nbsp;
<CustomLink
url="https://fleetdm.com/docs/using-fleet/fleet-ui#schedule-a-query"
text="Read about scheduling a query"
newTab
/>
</>
),
primaryButton: (
<Button
variant="brand"
className={`${baseClass}__schedule-button`}
onClick={toggleScheduleEditorModal}
>
Schedule a query
</Button>
),
};
if (selectedTeamData) {
emptySchedule.header = (
<>
Schedule queries for all hosts assigned to{" "}
<a
href={
MANAGE_HOSTS +
TAGGED_TEMPLATES.hostsByTeamRoute(selectedTeamData.id)
}
>
{selectedTeamData.name}
</a>
</>
);
}
/* NOTE: Product decision to remove packs from UI
if (isOnGlobalTeam) {
emptySchedule.info = (
<>Or go to your osquery packs via the Advanced button. </>
);
emptySchedule.secondaryButton = (
<Button
variant="inverse"
onClick={handleAdvanced}
className={`${baseClass}__advanced-button`}
>
Advanced
</Button>
);
}
*/
return emptySchedule;
};
const onActionSelection = (
action: string,
scheduledQuery: IEditScheduledQuery
): void => {
switch (action) {
case "edit":
if (onEditScheduledQueryClick) {
onEditScheduledQueryClick(scheduledQuery);
}
break;
case "showQuery":
if (onShowQueryClick) {
onShowQueryClick(scheduledQuery);
}
break;
default:
if (onRemoveScheduledQueryClick) {
onRemoveScheduledQueryClick([scheduledQuery.id]);
}
break;
}
};
const tableHeaders = generateTableHeaders(onActionSelection);
const loadingTableData = selectedTeamData?.id
? loadingTeamQueriesTableData
: loadingInheritedQueriesTableData;
if (inheritedQueries) {
const inheritedQueriesTableHeaders = generateInheritedQueriesTableHeaders();
return (
<div className={`${baseClass}`}>
<TableContainer
resultsTitle={"queries"}
columns={inheritedQueriesTableHeaders}
data={generateDataSet(allScheduledQueriesList, selectedTeamData?.id)}
isLoading={loadingInheritedQueriesTableData}
defaultSortHeader={"query"}
defaultSortDirection={"desc"}
showMarkAllPages={false}
isAllPagesSelected={false}
searchable={false}
disablePagination
disableCount
emptyComponent={() =>
EmptyTable({
iconName: emptyState().iconName,
header: emptyState().header,
info: emptyState().info,
additionalInfo: emptyState().additionalInfo,
primaryButton: emptyState().primaryButton,
secondaryButton: emptyState().secondaryButton,
})
}
/>
</div>
);
}
return (
<div className={`${baseClass}`}>
<TableContainer
resultsTitle={"queries"}
columns={tableHeaders}
data={generateDataSet(allScheduledQueriesList, selectedTeamData?.id)}
isLoading={loadingTableData}
defaultSortHeader={"query"}
defaultSortDirection={"desc"}
showMarkAllPages={false}
isAllPagesSelected={false}
inputPlaceHolder="Search"
searchable={false}
primarySelectAction={{
name: "remove scheduled query",
buttonText: "Remove",
iconSvg: "ex",
variant: "text-icon",
onActionButtonClick: onRemoveScheduledQueryClick,
}}
emptyComponent={() =>
EmptyTable({
iconName: emptyState().iconName,
header: emptyState().header,
info: emptyState().info,
additionalInfo: emptyState().additionalInfo,
primaryButton: emptyState().primaryButton,
secondaryButton: emptyState().secondaryButton,
})
}
isClientSidePagination
/>
</div>
);
};
export default ScheduleTable;
@@ -1,272 +0,0 @@
/* eslint-disable react/prop-types */
// disable this rule as it was throwing an error in Header and Cell component
// definitions for the selection row for some reason when we dont really need it.
import React from "react";
import { performanceIndicator, secondsToDhms } from "utilities/helpers";
// @ts-ignore
import Checkbox from "components/forms/fields/Checkbox";
import TextCell from "components/TableContainer/DataTable/TextCell";
import DropdownCell from "components/TableContainer/DataTable/DropdownCell";
import PillCell from "components/TableContainer/DataTable/PillCell";
import { IDropdownOption } from "interfaces/dropdownOption";
import {
IScheduledQuery,
IEditScheduledQuery,
} from "interfaces/scheduled_query";
import TooltipWrapper from "components/TooltipWrapper";
interface IGetToggleAllRowsSelectedProps {
checked: boolean;
indeterminate: boolean;
title: string;
onChange: () => void;
style: { cursor: string };
}
interface IHeaderProps {
column: {
title: string;
isSortedDesc: boolean;
};
getToggleAllRowsSelectedProps: () => IGetToggleAllRowsSelectedProps;
toggleAllRowsSelected: () => void;
}
interface IRowProps {
row: {
original: IEditScheduledQuery;
getToggleRowSelectedProps: () => IGetToggleAllRowsSelectedProps;
toggleRowSelected: () => void;
};
}
interface ICellProps extends IRowProps {
cell: {
value: string | number | boolean;
};
}
interface INumberCellProps extends IRowProps {
cell: {
value: number;
};
}
interface IPillCellProps extends IRowProps {
cell: {
value: { indicator: string; id: number };
};
}
interface IDropdownCellProps extends IRowProps {
cell: {
value: IDropdownOption[];
};
}
interface IDataColumn {
Header: ((props: IHeaderProps) => JSX.Element) | string;
Cell:
| ((props: ICellProps) => JSX.Element)
| ((props: INumberCellProps) => JSX.Element)
| ((props: IPillCellProps) => JSX.Element)
| ((props: IDropdownCellProps) => JSX.Element);
id?: string;
title?: string;
accessor?: string;
disableHidden?: boolean;
disableSortBy?: boolean;
}
interface IAllScheduledQueryTableData {
name: string;
interval: number;
actions: IDropdownOption[];
id: number;
type: string;
}
// NOTE: cellProps come from react-table
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
const generateTableHeaders = (
actionSelectHandler: (
value: string,
scheduledQuery: IEditScheduledQuery
) => void
): IDataColumn[] => {
return [
{
id: "selection",
Header: (cellProps: IHeaderProps): JSX.Element => {
const props = cellProps.getToggleAllRowsSelectedProps();
const checkboxProps = {
value: props.checked,
indeterminate: props.indeterminate,
onChange: () => cellProps.toggleAllRowsSelected(),
};
return <Checkbox {...checkboxProps} />;
},
Cell: (cellProps: ICellProps): JSX.Element => {
const props = cellProps.row.getToggleRowSelectedProps();
const checkboxProps = {
value: props.checked,
onChange: () => cellProps.row.toggleRowSelected(),
};
return <Checkbox {...checkboxProps} />;
},
disableHidden: true,
},
{
title: "Name",
Header: "Name",
disableSortBy: true,
accessor: "query_name",
Cell: (cellProps: ICellProps): JSX.Element => (
<TextCell value={cellProps.cell.value} />
),
},
{
title: "Frequency",
Header: "Frequency",
disableSortBy: true,
accessor: "interval",
Cell: (cellProps: INumberCellProps): JSX.Element => (
<TextCell value={secondsToDhms(cellProps.cell.value)} />
),
},
{
Header: () => {
return (
<div>
<TooltipWrapper
tipContent={`
This is the average <br />
performance impact <br />
across all hosts where this <br />
query was scheduled.`}
>
Performance impact
</TooltipWrapper>
</div>
);
},
disableSortBy: true,
accessor: "performance",
Cell: (cellProps: IPillCellProps) => (
<PillCell value={cellProps.cell.value} />
),
},
{
title: "Actions",
Header: "",
disableSortBy: true,
accessor: "actions",
Cell: (cellProps: IDropdownCellProps) => (
<DropdownCell
options={cellProps.cell.value}
onChange={(value: string) =>
actionSelectHandler(value, cellProps.row.original)
}
placeholder={"Actions"}
/>
),
},
];
};
const generateInheritedQueriesTableHeaders = (): IDataColumn[] => {
return [
{
title: "Query",
Header: "Query",
disableSortBy: true,
accessor: "query_name",
Cell: (cellProps: ICellProps): JSX.Element => (
<TextCell value={cellProps.cell.value} />
),
},
{
title: "Frequency",
Header: "Frequency",
disableSortBy: true,
accessor: "interval",
Cell: (cellProps: INumberCellProps): JSX.Element => (
<TextCell value={secondsToDhms(cellProps.cell.value)} />
),
},
{
title: "Performance impact",
Header: "Performance impact",
disableSortBy: true,
accessor: "performance",
Cell: (cellProps: IPillCellProps) => (
<PillCell value={cellProps.cell.value} />
),
},
];
};
const generateActionDropdownOptions = (): IDropdownOption[] => {
const dropdownOptions = [
{
label: "Edit",
disabled: false,
value: "edit",
},
{
label: "Show query",
disabled: false,
value: "showQuery",
},
{
label: "Remove",
disabled: false,
value: "remove",
},
];
return dropdownOptions;
};
const enhanceAllScheduledQueryData = (
allScheduledQueries: IScheduledQuery[],
teamId: number | undefined
): IAllScheduledQueryTableData[] => {
return allScheduledQueries.map((scheduledQuery: IScheduledQuery) => {
const scheduledQueryPerformance = {
user_time_p50: scheduledQuery.stats?.user_time_p50,
system_time_p50: scheduledQuery.stats?.system_time_p50,
total_executions: scheduledQuery.stats?.total_executions,
};
return {
name: scheduledQuery.name,
query_name: scheduledQuery.query_name,
interval: scheduledQuery.interval,
actions: generateActionDropdownOptions(),
id: scheduledQuery.id,
query: scheduledQuery.query,
query_id: scheduledQuery.query_id,
snapshot: scheduledQuery.snapshot,
removed: scheduledQuery.removed,
platform: scheduledQuery.platform,
version: scheduledQuery.version,
shard: scheduledQuery.shard,
type: teamId ? "team_scheduled_query" : "global_scheduled_query",
performance: {
indicator: performanceIndicator(scheduledQueryPerformance),
id: scheduledQuery.id,
},
};
});
};
const generateDataSet = (
allScheduledQueries: IScheduledQuery[],
teamId: number | undefined
): IAllScheduledQueryTableData[] => {
return [...enhanceAllScheduledQueryData(allScheduledQueries, teamId)];
};
export {
generateInheritedQueriesTableHeaders,
generateTableHeaders,
generateDataSet,
};
@@ -1 +0,0 @@
export { default } from "./ScheduleTable";
@@ -1 +0,0 @@
export { default } from "./ManageSchedulePage";
-10
View File
@@ -33,7 +33,6 @@ import ManageSoftwarePage from "pages/software/ManageSoftwarePage";
import ManageQueriesPage from "pages/queries/ManageQueriesPage";
import ManagePacksPage from "pages/packs/ManagePacksPage";
import ManagePoliciesPage from "pages/policies/ManagePoliciesPage";
import ManageSchedulePage from "pages/schedule/ManageSchedulePage";
import PackComposerPage from "pages/packs/PackComposerPage";
import PolicyPage from "pages/policies/PolicyPage";
import QueryPage from "pages/queries/QueryPage";
@@ -169,7 +168,6 @@ const routes = (
<Route component={HostDetailsPage}>
<Route path=":host_id" component={HostDetailsPage}>
<Route path="software" component={HostDetailsPage} />
<Route path="schedule" component={HostDetailsPage} />
<Route path="policies" component={HostDetailsPage} />
</Route>
</Route>
@@ -204,14 +202,6 @@ const routes = (
</Route>
</Route>
</Route>
<Route component={AuthAnyMaintainerAnyAdminRoutes}>
<Route path="schedule">
<IndexRedirect to="manage" />
<Route path="manage" component={ManageSchedulePage} />
<Redirect from="manage/teams" to="manage" />
<Redirect from="manage/teams/:team_id" to="manage" />
</Route>
</Route>
<Route path="queries">
<IndexRedirect to="manage" />
<Route path="manage" component={ManageQueriesPage} />
+13 -2
View File
@@ -4,6 +4,7 @@ import endpoints from "utilities/endpoints";
import { IQueryFormData } from "interfaces/query";
import { ISelectedTargets } from "interfaces/target";
import { AxiosResponse } from "axios";
import { buildQueryStringFromParams } from "utilities/url";
// Mock API requests to be used in developing FE for #7765 in parallel with BE development
// import { sendRequest } from "services/mock_service/service/service";
@@ -25,16 +26,26 @@ export default {
return sendRequest("DELETE", path);
},
bulkDestroy: (ids: number[]) => {
const { QUERIES } = endpoints;
const path = `${QUERIES}/delete`;
return sendRequest("POST", path, { ids });
},
load: (id: number) => {
const { QUERIES } = endpoints;
const path = `${QUERIES}/${id}`;
return sendRequest("GET", path);
},
loadAll: () => {
loadAll: (teamId?: number) => {
const { QUERIES } = endpoints;
const queryString = buildQueryStringFromParams({ team_id: teamId });
const path = `${QUERIES}`;
return sendRequest("GET", QUERIES);
return sendRequest(
"GET",
queryString ? path.concat(`?${queryString}`) : path
);
},
run: async ({
query,