fleet/frontend/pages/queries/edit/EditQueryPage.tsx
Nico 2e70ad2955
Surface queries in host details (#37646)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #27322 


[Figma](https://www.figma.com/design/v7WjL5zQuFIZerWYaSwy8o/-27322-Surface-custom-host-vitals?node-id=5636-4950&t=LuE3Kp09a5sj24Tt-0)

## Testing

- [x] Added/updated automated tests
- [ ] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [x] QA'd all new/changed functionality manually (WIP)

## Screenshots

### Host details

<img width="1481" height="1000" alt="Screenshot 2025-12-26 at 2 14
48 PM"
src="https://github.com/user-attachments/assets/3d9f02f9-f3a7-4a06-b3e4-414bb7b56e25"
/>

- `Queries` tab removed.
- Shows `Queries` card.

#### Queries Card

- Added client-side pagination.
- Added `Add query` button (screenshots below are with `Admin` role).

<img width="710" height="395" alt="Screenshot 2025-12-26 at 2 15 07 PM"
src="https://github.com/user-attachments/assets/b4e58269-d1b2-4c87-abfa-2cdfe47b533e"
/>

<img width="723" height="301" alt="Screenshot 2025-12-26 at 2 15 00 PM"
src="https://github.com/user-attachments/assets/2615d5bf-5d75-4e83-bc69-bc884232bf32"
/>

- As an `Observer`, `Add query` is not displayed

<img width="2240" height="1077" alt="Screenshot 2025-12-26 at 2 27
25 PM"
src="https://github.com/user-attachments/assets/426de709-d2ce-4bef-96f1-919ad5bddb13"
/>

- As a `Maintainer`, `Add query` is displayed

<img width="2236" height="1084" alt="Screenshot 2025-12-26 at 2 31
16 PM"
src="https://github.com/user-attachments/assets/218b0d18-2536-4336-88c8-41e7d09a5e9e"
/>



### New query page

If the user navigates from `Host details`, `host_id` search parameter is
added to the URL and the back button displays `Back to host details`.

<img width="1097" height="506" alt="Screenshot 2025-12-26 at 2 15 32 PM"
src="https://github.com/user-attachments/assets/61777c85-22f5-49dc-a3e6-dcd706119c70"
/>

### Host Queries (/hosts/:hostId/queries/:queryId)

`Performance impact` added above the table.

<img width="2029" height="626" alt="Screenshot 2025-12-26 at 2 16 00 PM"
src="https://github.com/user-attachments/assets/05c6b1bc-0587-4b0a-8167-142787592c6d"
/>
<img width="1555" height="482" alt="Screenshot 2025-12-26 at 2 16 05 PM"
src="https://github.com/user-attachments/assets/b9035b63-51c3-46c0-a903-c16d54c22986"
/>
2026-01-02 10:06:12 -03:00

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;