mirror of
https://github.com/fleetdm/fleet
synced 2026-05-24 09:28:54 +00:00
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #27322 [Figma](https://www.figma.com/design/v7WjL5zQuFIZerWYaSwy8o/-27322-Surface-custom-host-vitals?node-id=5636-4950&t=LuE3Kp09a5sj24Tt-0) ## Testing - [x] Added/updated automated tests - [ ] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [x] QA'd all new/changed functionality manually (WIP) ## Screenshots ### Host details <img width="1481" height="1000" alt="Screenshot 2025-12-26 at 2 14 48 PM" src="https://github.com/user-attachments/assets/3d9f02f9-f3a7-4a06-b3e4-414bb7b56e25" /> - `Queries` tab removed. - Shows `Queries` card. #### Queries Card - Added client-side pagination. - Added `Add query` button (screenshots below are with `Admin` role). <img width="710" height="395" alt="Screenshot 2025-12-26 at 2 15 07 PM" src="https://github.com/user-attachments/assets/b4e58269-d1b2-4c87-abfa-2cdfe47b533e" /> <img width="723" height="301" alt="Screenshot 2025-12-26 at 2 15 00 PM" src="https://github.com/user-attachments/assets/2615d5bf-5d75-4e83-bc69-bc884232bf32" /> - As an `Observer`, `Add query` is not displayed <img width="2240" height="1077" alt="Screenshot 2025-12-26 at 2 27 25 PM" src="https://github.com/user-attachments/assets/426de709-d2ce-4bef-96f1-919ad5bddb13" /> - As a `Maintainer`, `Add query` is displayed <img width="2236" height="1084" alt="Screenshot 2025-12-26 at 2 31 16 PM" src="https://github.com/user-attachments/assets/218b0d18-2536-4336-88c8-41e7d09a5e9e" /> ### New query page If the user navigates from `Host details`, `host_id` search parameter is added to the URL and the back button displays `Back to host details`. <img width="1097" height="506" alt="Screenshot 2025-12-26 at 2 15 32 PM" src="https://github.com/user-attachments/assets/61777c85-22f5-49dc-a3e6-dcd706119c70" /> ### Host Queries (/hosts/:hostId/queries/:queryId) `Performance impact` added above the table. <img width="2029" height="626" alt="Screenshot 2025-12-26 at 2 16 00 PM" src="https://github.com/user-attachments/assets/05c6b1bc-0587-4b0a-8167-142787592c6d" /> <img width="1555" height="482" alt="Screenshot 2025-12-26 at 2 16 05 PM" src="https://github.com/user-attachments/assets/b9035b63-51c3-46c0-a903-c16d54c22986" />
463 lines
15 KiB
TypeScript
463 lines
15 KiB
TypeScript
import React, { useState, useEffect, useContext } from "react";
|
|
import { useQuery } from "react-query";
|
|
import { useErrorHandler } from "react-error-boundary";
|
|
import { InjectedRouter, Params } from "react-router/lib/Router";
|
|
import { Location } from "history";
|
|
import PATHS from "router/paths";
|
|
|
|
import { AppContext } from "context/app";
|
|
import { NotificationContext } from "context/notification";
|
|
import { QueryContext } from "context/query";
|
|
import useTeamIdParam from "hooks/useTeamIdParam";
|
|
|
|
import debounce from "utilities/debounce";
|
|
import deepDifference from "utilities/deep_difference";
|
|
import { getPathWithQueryParams } from "utilities/url";
|
|
import {
|
|
DEFAULT_QUERY,
|
|
DOCUMENT_TITLE_SUFFIX,
|
|
INVALID_PLATFORMS_FLASH_MESSAGE,
|
|
INVALID_PLATFORMS_REASON,
|
|
} from "utilities/constants";
|
|
import configAPI from "services/entities/config";
|
|
import queryAPI from "services/entities/queries";
|
|
import statusAPI from "services/entities/status";
|
|
import {
|
|
IGetQueryResponse,
|
|
ICreateQueryRequestBody,
|
|
ISchedulableQuery,
|
|
} from "interfaces/schedulable_query";
|
|
import { IConfig } from "interfaces/config";
|
|
import { getErrorReason } from "interfaces/errors";
|
|
|
|
import QuerySidePanel from "components/side_panels/QuerySidePanel";
|
|
import MainContent from "components/MainContent";
|
|
import SidePanelPage from "components/SidePanelPage";
|
|
import SidePanelContent from "components/SidePanelContent";
|
|
import CustomLink from "components/CustomLink";
|
|
import BackButton from "components/BackButton";
|
|
import InfoBanner from "components/InfoBanner";
|
|
import EditQueryForm from "./components/EditQueryForm";
|
|
|
|
interface IEditQueryPageProps {
|
|
router: InjectedRouter;
|
|
params: Params;
|
|
location: Location<{ host_id: string; team_id?: string }>;
|
|
}
|
|
|
|
const baseClass = "edit-query-page";
|
|
|
|
const EditQueryPage = ({
|
|
router,
|
|
params: { id: paramsQueryId },
|
|
location,
|
|
}: IEditQueryPageProps): JSX.Element => {
|
|
const queryId = paramsQueryId ? parseInt(paramsQueryId, 10) : null;
|
|
const hostId = location.query.host_id
|
|
? parseInt(location.query.host_id as string, 10)
|
|
: undefined;
|
|
|
|
const {
|
|
currentTeamName: teamNameForQuery,
|
|
teamIdForApi: apiTeamIdForQuery,
|
|
currentTeamId,
|
|
} = useTeamIdParam({
|
|
location,
|
|
router,
|
|
includeAllTeams: true,
|
|
includeNoTeam: false,
|
|
});
|
|
|
|
const handlePageError = useErrorHandler();
|
|
const {
|
|
isGlobalAdmin,
|
|
isGlobalMaintainer,
|
|
isTeamMaintainerOrTeamAdmin,
|
|
isAnyTeamMaintainerOrTeamAdmin,
|
|
isObserverPlus,
|
|
isAnyTeamObserverPlus,
|
|
config,
|
|
filteredQueriesPath,
|
|
isOnGlobalTeam,
|
|
} = useContext(AppContext);
|
|
const {
|
|
editingExistingQuery,
|
|
selectedOsqueryTable,
|
|
setSelectedOsqueryTable,
|
|
lastEditedQueryName,
|
|
lastEditedQueryDescription,
|
|
lastEditedQueryBody,
|
|
lastEditedQueryObserverCanRun,
|
|
lastEditedQueryFrequency,
|
|
lastEditedQueryAutomationsEnabled,
|
|
lastEditedQueryPlatforms,
|
|
lastEditedQueryLoggingType,
|
|
lastEditedQueryMinOsqueryVersion,
|
|
lastEditedQueryDiscardData,
|
|
setLastEditedQueryId,
|
|
setLastEditedQueryName,
|
|
setLastEditedQueryDescription,
|
|
setLastEditedQueryBody,
|
|
setLastEditedQueryObserverCanRun,
|
|
setLastEditedQueryFrequency,
|
|
setLastEditedQueryAutomationsEnabled,
|
|
setLastEditedQueryLoggingType,
|
|
setLastEditedQueryMinOsqueryVersion,
|
|
setLastEditedQueryPlatforms,
|
|
setLastEditedQueryDiscardData,
|
|
} = useContext(QueryContext);
|
|
const { setConfig, availableTeams, setCurrentTeam } = useContext(AppContext);
|
|
const { renderFlash } = useContext(NotificationContext);
|
|
|
|
const [isLiveQueryRunnable, setIsLiveQueryRunnable] = useState(true);
|
|
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
|
const [showOpenSchemaActionText, setShowOpenSchemaActionText] = useState(
|
|
false
|
|
);
|
|
const [
|
|
showConfirmSaveChangesModal,
|
|
setShowConfirmSaveChangesModal,
|
|
] = useState(false);
|
|
|
|
const { data: appConfig } = useQuery<IConfig, Error, IConfig>(
|
|
["config"],
|
|
() => configAPI.loadAll(),
|
|
{
|
|
select: (data: IConfig) => data,
|
|
onSuccess: (data) => {
|
|
setConfig(data);
|
|
},
|
|
}
|
|
);
|
|
|
|
// disabled on page load so we can control the number of renders
|
|
// else it will re-populate the context on occasion
|
|
const {
|
|
isLoading: isStoredQueryLoading,
|
|
data: storedQuery,
|
|
refetch: refetchStoredQuery,
|
|
} = useQuery<IGetQueryResponse, Error, ISchedulableQuery>(
|
|
["query", queryId],
|
|
() => queryAPI.load(queryId as number),
|
|
{
|
|
enabled: !!queryId && !editingExistingQuery,
|
|
refetchOnWindowFocus: false,
|
|
select: (data) => data.query,
|
|
onSuccess: (returnedQuery) => {
|
|
setLastEditedQueryId(returnedQuery.id);
|
|
setLastEditedQueryName(returnedQuery.name);
|
|
setLastEditedQueryDescription(returnedQuery.description);
|
|
setLastEditedQueryBody(returnedQuery.query);
|
|
setLastEditedQueryObserverCanRun(returnedQuery.observer_can_run);
|
|
setLastEditedQueryFrequency(returnedQuery.interval);
|
|
setLastEditedQueryAutomationsEnabled(returnedQuery.automations_enabled);
|
|
setLastEditedQueryPlatforms(returnedQuery.platform);
|
|
setLastEditedQueryLoggingType(returnedQuery.logging);
|
|
setLastEditedQueryMinOsqueryVersion(returnedQuery.min_osquery_version);
|
|
setLastEditedQueryDiscardData(returnedQuery.discard_data);
|
|
},
|
|
onError: (error) => handlePageError(error),
|
|
}
|
|
);
|
|
|
|
/** Pesky bug affecting team level users:
|
|
- Navigating to queries/:id immediately defaults the user to the first team they're on
|
|
with the most permissions, in the URL bar because of useTeamIdParam
|
|
even if the queries/:id entity has a team attached to it
|
|
Hacky fix:
|
|
- Push entity's team id to url for team level users
|
|
*/
|
|
if (
|
|
!isOnGlobalTeam &&
|
|
!isStoredQueryLoading &&
|
|
storedQuery?.team_id &&
|
|
!(storedQuery?.team_id?.toString() === location.query.team_id)
|
|
) {
|
|
router.push(
|
|
getPathWithQueryParams(location.pathname, {
|
|
team_id: storedQuery?.team_id?.toString(),
|
|
host_id: hostId,
|
|
})
|
|
);
|
|
}
|
|
|
|
// Used to set host's team in AppContext for RBAC actions
|
|
useEffect(() => {
|
|
if (storedQuery?.team_id) {
|
|
const querysTeam = availableTeams?.find(
|
|
(team) => team.id === storedQuery.team_id
|
|
);
|
|
setCurrentTeam(querysTeam);
|
|
}
|
|
}, [storedQuery]);
|
|
|
|
const detectIsFleetQueryRunnable = () => {
|
|
statusAPI.live_query().catch(() => {
|
|
setIsLiveQueryRunnable(false);
|
|
});
|
|
};
|
|
|
|
/* Observer/Observer+ cannot edit existing query (O+ has access to edit new query to run live),
|
|
Team admin/team maintainer cannot edit existing query,
|
|
reroute edit existing query page (/:queryId/edit) to query report page (/:queryId) */
|
|
useEffect(() => {
|
|
const canEditExistingQuery =
|
|
isGlobalAdmin ||
|
|
isGlobalMaintainer ||
|
|
(isTeamMaintainerOrTeamAdmin && storedQuery?.team_id);
|
|
|
|
if (
|
|
!isStoredQueryLoading && // Confirms teamId for storedQuery before RBAC reroute
|
|
queryId &&
|
|
queryId > 0 &&
|
|
!canEditExistingQuery
|
|
) {
|
|
// Reroute to query report page still maintains query params for live query purposes
|
|
router.push(
|
|
getPathWithQueryParams(PATHS.QUERY_DETAILS(queryId), {
|
|
host_id: location.query.host_id,
|
|
team_id: location.query.team_id,
|
|
})
|
|
);
|
|
}
|
|
}, [queryId, isTeamMaintainerOrTeamAdmin, isStoredQueryLoading]);
|
|
|
|
useEffect(() => {
|
|
detectIsFleetQueryRunnable();
|
|
if (!queryId) {
|
|
setLastEditedQueryId(DEFAULT_QUERY.id);
|
|
setLastEditedQueryName(DEFAULT_QUERY.name);
|
|
setLastEditedQueryDescription(DEFAULT_QUERY.description);
|
|
// Persist lastEditedQueryBody through live query flow instead of resetting to DEFAULT_QUERY.query
|
|
setLastEditedQueryObserverCanRun(DEFAULT_QUERY.observer_can_run);
|
|
setLastEditedQueryFrequency(DEFAULT_QUERY.interval);
|
|
setLastEditedQueryAutomationsEnabled(DEFAULT_QUERY.automations_enabled);
|
|
setLastEditedQueryLoggingType(DEFAULT_QUERY.logging);
|
|
setLastEditedQueryMinOsqueryVersion(DEFAULT_QUERY.min_osquery_version);
|
|
setLastEditedQueryPlatforms(DEFAULT_QUERY.platform);
|
|
setLastEditedQueryDiscardData(DEFAULT_QUERY.discard_data);
|
|
}
|
|
}, [queryId]);
|
|
|
|
const [isQuerySaving, setIsQuerySaving] = useState(false);
|
|
const [isQueryUpdating, setIsQueryUpdating] = useState(false);
|
|
const [backendValidators, setBackendValidators] = useState<{
|
|
[key: string]: string;
|
|
}>({});
|
|
|
|
// Updates title that shows up on browser tabs
|
|
useEffect(() => {
|
|
// e.g., Editing Discover TLS certificates | Queries | Fleet
|
|
const storedQueryTitleCopy = storedQuery?.name
|
|
? `Editing ${storedQuery.name} | `
|
|
: "";
|
|
document.title = `${storedQueryTitleCopy}Queries | ${DOCUMENT_TITLE_SUFFIX}`;
|
|
// }
|
|
}, [location.pathname, storedQuery?.name]);
|
|
|
|
useEffect(() => {
|
|
setShowOpenSchemaActionText(!isSidebarOpen);
|
|
}, [isSidebarOpen]);
|
|
|
|
const onSubmitNewQuery = debounce(
|
|
async (formData: ICreateQueryRequestBody) => {
|
|
setIsQuerySaving(true);
|
|
try {
|
|
const { query } = await queryAPI.create(formData);
|
|
router.push(
|
|
getPathWithQueryParams(PATHS.QUERY_DETAILS(query.id), {
|
|
team_id: query.team_id,
|
|
host_id: hostId,
|
|
})
|
|
);
|
|
renderFlash("success", "Query created!");
|
|
setBackendValidators({});
|
|
} catch (createError: any) {
|
|
if (getErrorReason(createError).includes("already exists")) {
|
|
const teamErrorText =
|
|
teamNameForQuery && apiTeamIdForQuery !== 0
|
|
? `the ${teamNameForQuery} team`
|
|
: "all teams";
|
|
setBackendValidators({
|
|
name: `A query with that name already exists for ${teamErrorText}.`,
|
|
});
|
|
} else {
|
|
renderFlash(
|
|
"error",
|
|
"Something went wrong creating your query. Please try again."
|
|
);
|
|
setBackendValidators({});
|
|
}
|
|
} finally {
|
|
setIsQuerySaving(false);
|
|
}
|
|
}
|
|
);
|
|
|
|
const onUpdateQuery = async (formData: ICreateQueryRequestBody) => {
|
|
if (!queryId) {
|
|
return false;
|
|
}
|
|
|
|
setIsQueryUpdating(true);
|
|
|
|
const updatedQuery = deepDifference(formData, {
|
|
lastEditedQueryName,
|
|
lastEditedQueryDescription,
|
|
lastEditedQueryBody,
|
|
lastEditedQueryObserverCanRun,
|
|
lastEditedQueryFrequency,
|
|
lastEditedQueryAutomationsEnabled,
|
|
lastEditedQueryPlatforms,
|
|
lastEditedQueryLoggingType,
|
|
lastEditedQueryMinOsqueryVersion,
|
|
lastEditedQueryDiscardData,
|
|
});
|
|
|
|
try {
|
|
await queryAPI.update(queryId, updatedQuery);
|
|
renderFlash("success", "Query updated!");
|
|
refetchStoredQuery(); // Required to compare recently saved query to a subsequent save to the query
|
|
} catch (updateError: any) {
|
|
console.error(updateError);
|
|
const reason = getErrorReason(updateError);
|
|
if (reason.includes("Duplicate")) {
|
|
renderFlash("error", "A query with this name already exists.");
|
|
} else if (reason.includes(INVALID_PLATFORMS_REASON)) {
|
|
renderFlash("error", INVALID_PLATFORMS_FLASH_MESSAGE);
|
|
} else {
|
|
renderFlash(
|
|
"error",
|
|
"Something went wrong updating your query. Please try again."
|
|
);
|
|
}
|
|
}
|
|
|
|
setIsQueryUpdating(false);
|
|
setShowConfirmSaveChangesModal(false); // Closes conditionally opened modal when discarding previous results
|
|
|
|
return false;
|
|
};
|
|
|
|
const onOsqueryTableSelect = (tableName: string) => {
|
|
setSelectedOsqueryTable(tableName);
|
|
};
|
|
|
|
const onCloseSchemaSidebar = () => {
|
|
setIsSidebarOpen(false);
|
|
};
|
|
|
|
const onOpenSchemaSidebar = () => {
|
|
setIsSidebarOpen(true);
|
|
};
|
|
|
|
const renderLiveQueryWarning = (): JSX.Element | null => {
|
|
if (isLiveQueryRunnable || config?.server_settings.live_query_disabled) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<InfoBanner color="yellow">
|
|
Fleet is unable to run a live query. Refresh the page or log in again.
|
|
If this keeps happening please{" "}
|
|
<CustomLink
|
|
url="https://github.com/fleetdm/fleet/issues/new/choose"
|
|
text="file an issue"
|
|
newTab
|
|
variant="banner-link"
|
|
/>
|
|
</InfoBanner>
|
|
);
|
|
};
|
|
|
|
// Function instead of constant eliminates race condition
|
|
// Returns to queries details page, manage queries page with filters, or default manage queries page
|
|
const backPath = () => {
|
|
if (queryId) {
|
|
return getPathWithQueryParams(PATHS.QUERY_DETAILS(queryId), {
|
|
team_id: currentTeamId,
|
|
host_id: hostId,
|
|
});
|
|
}
|
|
|
|
if (hostId) {
|
|
return getPathWithQueryParams(PATHS.HOST_DETAILS(hostId));
|
|
}
|
|
|
|
if (filteredQueriesPath) return filteredQueriesPath;
|
|
|
|
return getPathWithQueryParams(PATHS.MANAGE_QUERIES, {
|
|
team_id: currentTeamId,
|
|
});
|
|
};
|
|
|
|
const backButtonText = () => {
|
|
if (queryId) {
|
|
return "Back to report";
|
|
}
|
|
|
|
if (hostId) {
|
|
return "Back to host details";
|
|
}
|
|
|
|
return "Back to queries";
|
|
};
|
|
|
|
const showSidebar =
|
|
isSidebarOpen &&
|
|
(isGlobalAdmin ||
|
|
isGlobalMaintainer ||
|
|
isAnyTeamMaintainerOrTeamAdmin ||
|
|
isObserverPlus ||
|
|
isAnyTeamObserverPlus);
|
|
|
|
return (
|
|
<SidePanelPage>
|
|
<>
|
|
<MainContent className={baseClass}>
|
|
<>
|
|
<div className={`${baseClass}__header-links`}>
|
|
<BackButton text={backButtonText()} path={backPath()} />
|
|
</div>
|
|
<EditQueryForm
|
|
router={router}
|
|
location={location}
|
|
onSubmitNewQuery={onSubmitNewQuery}
|
|
onOsqueryTableSelect={onOsqueryTableSelect}
|
|
onUpdate={onUpdateQuery}
|
|
storedQuery={storedQuery}
|
|
queryIdForEdit={queryId}
|
|
apiTeamIdForQuery={apiTeamIdForQuery}
|
|
currentTeamId={currentTeamId}
|
|
currentTeamName={teamNameForQuery}
|
|
isStoredQueryLoading={isStoredQueryLoading}
|
|
showOpenSchemaActionText={showOpenSchemaActionText}
|
|
onOpenSchemaSidebar={onOpenSchemaSidebar}
|
|
renderLiveQueryWarning={renderLiveQueryWarning}
|
|
backendValidators={backendValidators}
|
|
isQuerySaving={isQuerySaving}
|
|
isQueryUpdating={isQueryUpdating}
|
|
hostId={hostId}
|
|
queryReportsDisabled={
|
|
appConfig?.server_settings.query_reports_disabled
|
|
}
|
|
showConfirmSaveChangesModal={showConfirmSaveChangesModal}
|
|
setShowConfirmSaveChangesModal={setShowConfirmSaveChangesModal}
|
|
/>
|
|
</>
|
|
</MainContent>
|
|
{showSidebar && (
|
|
<SidePanelContent>
|
|
<QuerySidePanel
|
|
onOsqueryTableSelect={onOsqueryTableSelect}
|
|
selectedOsqueryTable={selectedOsqueryTable}
|
|
onClose={onCloseSchemaSidebar}
|
|
/>
|
|
</SidePanelContent>
|
|
)}
|
|
</>
|
|
</SidePanelPage>
|
|
);
|
|
};
|
|
|
|
export default EditQueryPage;
|