Fleet UI: Policy details page followup (#43324)

This commit is contained in:
RachelElysia 2026-04-10 09:43:42 -04:00 committed by GitHub
parent fe72a6c1c4
commit 1d96eb2e3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 337 additions and 160 deletions

View file

@ -0,0 +1,3 @@
- Fleet UI: Added new policy details page with read-only view of policy information
- Fleet UI: Updated edit policy page to redirect users with read-only access to policy details page.
- Fleet UI: Added dedicated `/policies/:id/live` route for running policies

View file

@ -132,7 +132,7 @@ const generateTableHeaders = (
</>
}
path={getPathWithQueryParams(PATHS.POLICY_DETAILS(id), {
team_id,
fleet_id: team_id,
})}
/>
);

View file

@ -1 +0,0 @@
export { default } from "./PolicyPage";

View file

@ -11,6 +11,7 @@ import { ILabelPolicy } from "interfaces/label";
import { API_ALL_TEAMS_ID, APP_CONTEXT_ALL_TEAMS_ID } from "interfaces/team";
import { PLATFORM_DISPLAY_NAMES, Platform } from "interfaces/platform";
import globalPoliciesAPI from "services/entities/global_policies";
import teamPoliciesAPI from "services/entities/team_policies";
import teamsAPI, { ILoadTeamResponse } from "services/entities/teams";
import { addGravatarUrlToResource } from "utilities/helpers";
import { DOCUMENT_TITLE_SUFFIX } from "utilities/constants";
@ -27,7 +28,7 @@ import Spinner from "components/Spinner";
import TooltipWrapper from "components/TooltipWrapper";
import Avatar from "components/Avatar";
import ShowQueryModal from "components/modals/ShowQueryModal";
import PolicyAutomations from "pages/policies/PolicyPage/components/PolicyAutomations";
import PolicyAutomations from "pages/policies/edit/components/PolicyAutomations";
interface IPolicyDetailsPageProps {
router: InjectedRouter;
@ -35,7 +36,7 @@ interface IPolicyDetailsPageProps {
location: {
pathname: string;
search: string;
query: { team_id?: string };
query: { fleet_id?: string };
};
}
@ -108,34 +109,41 @@ const PolicyDetailsPage = ({
IStoredPolicyResponse,
Error,
IPolicy
>(["policy", policyId], () => globalPoliciesAPI.load(policyId as number), {
enabled: isRouteOk && !!policyId,
refetchOnWindowFocus: false,
retry: false,
select: (data: IStoredPolicyResponse) => data.policy,
onSuccess: (returnedPolicy) => {
setLastEditedQueryId(returnedPolicy.id);
setLastEditedQueryName(returnedPolicy.name);
setLastEditedQueryDescription(returnedPolicy.description);
setLastEditedQueryBody(returnedPolicy.query);
setLastEditedQueryResolution(returnedPolicy.resolution);
setLastEditedQueryCritical(returnedPolicy.critical);
setLastEditedQueryPlatform(returnedPolicy.platform);
setLastEditedQueryLabelsIncludeAny(
returnedPolicy.labels_include_any || []
);
setLastEditedQueryLabelsExcludeAny(
returnedPolicy.labels_exclude_any || []
);
const deNulledTeamId = returnedPolicy.team_id ?? undefined;
setPolicyTeamId(
deNulledTeamId === API_ALL_TEAMS_ID
? APP_CONTEXT_ALL_TEAMS_ID
: deNulledTeamId
);
},
onError: (error) => handlePageError(error),
});
>(
["policy", policyId, teamIdForApi],
() =>
teamIdForApi && teamIdForApi > 0
? teamPoliciesAPI.load(teamIdForApi, policyId as number)
: globalPoliciesAPI.load(policyId as number),
{
enabled: isRouteOk && !!policyId,
refetchOnWindowFocus: false,
retry: false,
select: (data: IStoredPolicyResponse) => data.policy,
onSuccess: (returnedPolicy) => {
setLastEditedQueryId(returnedPolicy.id);
setLastEditedQueryName(returnedPolicy.name);
setLastEditedQueryDescription(returnedPolicy.description);
setLastEditedQueryBody(returnedPolicy.query);
setLastEditedQueryResolution(returnedPolicy.resolution);
setLastEditedQueryCritical(returnedPolicy.critical);
setLastEditedQueryPlatform(returnedPolicy.platform);
setLastEditedQueryLabelsIncludeAny(
returnedPolicy.labels_include_any || []
);
setLastEditedQueryLabelsExcludeAny(
returnedPolicy.labels_exclude_any || []
);
const deNulledTeamId = returnedPolicy.team_id ?? undefined;
setPolicyTeamId(
deNulledTeamId === API_ALL_TEAMS_ID
? APP_CONTEXT_ALL_TEAMS_ID
: deNulledTeamId
);
},
onError: (error) => handlePageError(error),
}
);
const { data: teamData } = useQuery<ILoadTeamResponse>(
["team", teamIdForApi],
@ -187,7 +195,7 @@ const PolicyDetailsPage = ({
const disabledLiveQuery = config?.server_settings.live_query_disabled;
const backToPoliciesPath = getPathWithQueryParams(PATHS.MANAGE_POLICIES, {
team_id: teamIdForApi,
fleet_id: teamIdForApi,
});
const renderAuthor = (): JSX.Element | null => {
@ -329,12 +337,9 @@ const PolicyDetailsPage = ({
onClick={() => {
policyId &&
router.push(
`${getPathWithQueryParams(
PATHS.EDIT_POLICY(policyId),
{
team_id: teamIdForApi,
}
)}#targets`
getPathWithQueryParams(PATHS.LIVE_POLICY(policyId), {
fleet_id: teamIdForApi,
})
);
}}
disabled={!!disabledLiveQuery}
@ -348,7 +353,7 @@ const PolicyDetailsPage = ({
policyId &&
router.push(
getPathWithQueryParams(PATHS.EDIT_POLICY(policyId), {
team_id: teamIdForApi,
fleet_id: teamIdForApi,
})
);
}}

View file

@ -6,36 +6,27 @@ import { useErrorHandler } from "react-error-boundary";
import { AppContext } from "context/app";
import { PolicyContext } from "context/policy";
import useTeamIdParam from "hooks/useTeamIdParam";
import { IHost, IHostResponse } from "interfaces/host";
import { ILabel } from "interfaces/label";
import {
IPolicyFormData,
IPolicy,
IStoredPolicyResponse,
} from "interfaces/policy";
import { ITarget } from "interfaces/target";
import {
API_ALL_TEAMS_ID,
APP_CONTEXT_ALL_TEAMS_ID,
ITeam,
} from "interfaces/team";
import { API_ALL_TEAMS_ID, APP_CONTEXT_ALL_TEAMS_ID } from "interfaces/team";
import globalPoliciesAPI from "services/entities/global_policies";
import teamPoliciesAPI from "services/entities/team_policies";
import teamsAPI, { ILoadTeamResponse } from "services/entities/teams";
import hostAPI from "services/entities/hosts";
import statusAPI from "services/entities/status";
import { DOCUMENT_TITLE_SUFFIX, LIVE_POLICY_STEPS } from "utilities/constants";
import PATHS from "router/paths";
import { DOCUMENT_TITLE_SUFFIX } from "utilities/constants";
import { getPathWithQueryParams } from "utilities/url";
import SidePanelPage from "components/SidePanelPage";
import QuerySidePanel from "components/side_panels/QuerySidePanel";
import QueryEditor from "pages/policies/PolicyPage/screens/QueryEditor";
import SelectTargets from "components/LiveQuery/SelectTargets";
import QueryEditor from "pages/policies/edit/screens/QueryEditor";
import MainContent from "components/MainContent";
import SidePanelContent from "components/SidePanelContent";
import Spinner from "components/Spinner/Spinner";
import CustomLink from "components/CustomLink";
import RunQuery from "pages/policies/PolicyPage/screens/RunQuery";
import { DEFAULT_POLICY } from "pages/policies/constants";
interface IPolicyPageProps {
@ -44,12 +35,11 @@ interface IPolicyPageProps {
location: {
pathname: string;
search: string;
query: { host_ids: string; fleet_id: string };
hash?: string;
query: { fleet_id: string };
};
}
const baseClass = "policy-page";
const baseClass = "edit-policy-page";
const PolicyPage = ({
router,
@ -144,14 +134,6 @@ const PolicyPage = ({
};
}, []);
const [step, setStep] = useState(
location.hash === "#targets" ? LIVE_POLICY_STEPS[2] : LIVE_POLICY_STEPS[1]
);
const [selectedTargets, setSelectedTargets] = useState<ITarget[]>([]);
const [targetedHosts, setTargetedHosts] = useState<IHost[]>([]);
const [targetedLabels, setTargetedLabels] = useState<ILabel[]>([]);
const [targetedTeams, setTargetedTeams] = useState<ITeam[]>([]);
const [targetsTotalCount, setTargetsTotalCount] = useState(0);
const [isLiveQueryRunnable, setIsLiveQueryRunnable] = useState(true);
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [showOpenSchemaActionText, setShowOpenSchemaActionText] = useState(
@ -203,23 +185,6 @@ const PolicyPage = ({
}
);
useQuery<IHostResponse, Error, IHost>(
"hostFromURL",
() =>
hostAPI.loadHostDetails(parseInt(location.query.host_ids as string, 10)), // TODO(sarah): What should happen if this doesn't parse (e.g. the string is "foo")? Also, note that "1,2,3" parses as 1.
{
enabled: isRouteOk && !!location.query.host_ids,
retry: false,
select: (data: IHostResponse) => data.host,
onSuccess: (host) => {
const targets = selectedTargets;
host.target_type = "hosts";
targets.push(host);
setSelectedTargets([...targets]);
},
}
);
/** Pesky bug affecting team level users:
- Navigating to policies/:id immediately defaults the user to the first team they're on
with the most permissions, in the URL bar because of useTeamIdParam
@ -338,7 +303,7 @@ const PolicyPage = ({
};
const renderScreen = () => {
const step1Opts = {
const queryEditorOpts = {
router,
baseClass,
policyIdForEdit: policyId,
@ -352,51 +317,22 @@ const PolicyPage = ({
storedPolicyError,
createPolicy,
onOsqueryTableSelect,
goToSelectTargets: () => setStep(LIVE_POLICY_STEPS[2]),
goToSelectTargets: () =>
router.push(
getPathWithQueryParams(PATHS.LIVE_POLICY(policyId), {
fleet_id: teamIdForApi,
})
),
onOpenSchemaSidebar,
renderLiveQueryWarning,
teamIdForApi,
currentAutomatedPolicies,
};
const step2Opts = {
baseClass,
selectedTargets,
targetedHosts,
targetedLabels,
targetedTeams,
targetsTotalCount,
goToQueryEditor: () => setStep(LIVE_POLICY_STEPS[1]),
goToRunQuery: () => setStep(LIVE_POLICY_STEPS[3]),
setSelectedTargets,
setTargetedHosts,
setTargetedLabels,
setTargetedTeams,
setTargetsTotalCount,
isLivePolicy: true,
};
const step3Opts = {
selectedTargets,
storedPolicy,
setSelectedTargets,
goToQueryEditor: () => setStep(LIVE_POLICY_STEPS[1]),
targetsTotalCount,
};
switch (step) {
case LIVE_POLICY_STEPS[2]:
return <SelectTargets {...step2Opts} />;
case LIVE_POLICY_STEPS[3]:
return <RunQuery {...step3Opts} />;
default:
return <QueryEditor {...step1Opts} />;
}
return <QueryEditor {...queryEditorOpts} />;
};
const isFirstStep = step === LIVE_POLICY_STEPS[1];
const showSidebar =
isFirstStep &&
isSidebarOpen &&
(isGlobalAdmin || isGlobalMaintainer || isAnyTeamMaintainerOrTeamAdmin);

View file

@ -0,0 +1,39 @@
.edit-policy-page {
@include vertical-page-layout;
&__form {
@include vertical-form-layout;
}
&__warning {
padding: $pad-medium;
font-size: $x-small;
color: $core-fleet-black;
background-color: #fff0b9;
border: 1px solid #f2c94c;
border-radius: $border-radius;
p {
margin: 0;
line-height: 20px;
}
}
&__observer-query-view {
width: 90%;
max-width: 1060px;
margin: 0 auto;
color: $core-fleet-black;
h1 {
font-size: $medium;
}
p {
font-size: $x-small;
}
}
.ace_content {
min-height: 500px !important;
}
}

View file

@ -271,7 +271,19 @@ const PolicyForm = ({
})
);
}
}, [policyIdForEdit, isTeamMaintainerOrTeamAdmin, isStoredPolicyLoading]);
}, [
policyIdForEdit,
isEditMode,
isStoredPolicyLoading,
isTeamObserver,
isGlobalObserver,
isTeamTechnician,
isGlobalTechnician,
isOnGlobalTeam,
storedPolicy?.team_id,
router,
teamIdForApi,
]);
useEffect(() => {
setSelectedTargetType(

View file

@ -7,7 +7,7 @@
position: relative;
font-size: $x-small;
.policy-page__warning {
.edit-policy-page__warning {
margin: 0;
margin-bottom: $pad-large;
}

View file

@ -0,0 +1 @@
export { default } from "./EditPolicyPage";

View file

@ -14,7 +14,7 @@ import { getPathWithQueryParams } from "utilities/url";
import { IPolicyFormData, IPolicy } from "interfaces/policy";
import BackButton from "components/BackButton";
import PolicyForm from "pages/policies/PolicyPage/components/PolicyForm";
import PolicyForm from "pages/policies/edit/components/PolicyForm";
import { APP_CONTEXT_ALL_TEAMS_ID } from "interfaces/team";
interface IQueryEditorProps {
@ -261,7 +261,7 @@ const QueryEditor = ({
const backPath = policyIdForEdit
? getPathWithQueryParams(PATHS.POLICY_DETAILS(policyIdForEdit), {
team_id: teamIdForApi,
fleet_id: teamIdForApi,
})
: backToPoliciesPath();

View file

@ -0,0 +1,204 @@
import React, { useState, useEffect, useContext, useCallback } from "react";
import { useQuery } from "react-query";
import { useErrorHandler } from "react-error-boundary";
import { InjectedRouter, Params } from "react-router/lib/Router";
import PATHS from "router/paths";
import useTeamIdParam from "hooks/useTeamIdParam";
import { AppContext } from "context/app";
import { PolicyContext } from "context/policy";
import { LIVE_QUERY_STEPS, DOCUMENT_TITLE_SUFFIX } from "utilities/constants";
import { getPathWithQueryParams } from "utilities/url";
import globalPoliciesAPI from "services/entities/global_policies";
import teamPoliciesAPI from "services/entities/team_policies";
import hostAPI from "services/entities/hosts";
import { IHost, IHostResponse } from "interfaces/host";
import { ILabel } from "interfaces/label";
import { ITeam } from "interfaces/team";
import { IPolicy, IStoredPolicyResponse } from "interfaces/policy";
import { ITarget } from "interfaces/target";
import MainContent from "components/MainContent";
import SelectTargets from "components/LiveQuery/SelectTargets";
import RunQuery from "pages/policies/live/screens/RunQuery";
interface ILivePolicyPageProps {
router: InjectedRouter;
params: Params;
location: {
pathname: string;
query: { host_ids?: string; fleet_id?: string };
search: string;
};
}
const baseClass = "live-policy-page";
const LivePolicyPage = ({
router,
params: { id: paramsPolicyId },
location,
}: ILivePolicyPageProps): JSX.Element => {
const policyId = paramsPolicyId ? parseInt(paramsPolicyId, 10) : null;
const handlePageError = useErrorHandler();
const { currentTeamId } = useTeamIdParam({
location,
router,
includeAllTeams: true,
includeNoTeam: true,
});
const { config } = useContext(AppContext);
const {
setLastEditedQueryId,
setLastEditedQueryName,
setLastEditedQueryDescription,
setLastEditedQueryBody,
setLastEditedQueryResolution,
setLastEditedQueryCritical,
setLastEditedQueryPlatform,
setLastEditedQueryLabelsIncludeAny,
setLastEditedQueryLabelsExcludeAny,
} = useContext(PolicyContext);
const [queryParamHostsAdded, setQueryParamHostsAdded] = useState(false);
const [step, setStep] = useState(LIVE_QUERY_STEPS[1]);
const [selectedTargets, setSelectedTargets] = useState<ITarget[]>([]);
const [targetedHosts, setTargetedHosts] = useState<IHost[]>([]);
const [targetedLabels, setTargetedLabels] = useState<ILabel[]>([]);
const [targetedTeams, setTargetedTeams] = useState<ITeam[]>([]);
const [targetsTotalCount, setTargetsTotalCount] = useState(0);
const disabledLiveQuery = config?.server_settings.live_query_disabled;
const teamIdForApi = currentTeamId === -1 ? undefined : currentTeamId;
// Reroute users out of live flow when live queries are globally disabled
// Reroute users out of live flow when live queries are globally disabled
useEffect(() => {
if (disabledLiveQuery) {
const path = policyId
? PATHS.POLICY_DETAILS(policyId)
: PATHS.MANAGE_POLICIES;
router.push(getPathWithQueryParams(path, { fleet_id: teamIdForApi }));
}
}, [disabledLiveQuery, policyId, router, teamIdForApi]);
const { data: storedPolicy } = useQuery<
IStoredPolicyResponse,
Error,
IPolicy
>(
["policy", policyId, teamIdForApi],
() =>
teamIdForApi && teamIdForApi > 0
? teamPoliciesAPI.load(teamIdForApi, policyId as number)
: globalPoliciesAPI.load(policyId as number),
{
enabled: !!policyId,
refetchOnWindowFocus: false,
select: (data: IStoredPolicyResponse) => data.policy,
onSuccess: (returnedPolicy) => {
setLastEditedQueryId(returnedPolicy.id);
setLastEditedQueryName(returnedPolicy.name);
setLastEditedQueryDescription(returnedPolicy.description);
setLastEditedQueryBody(returnedPolicy.query);
setLastEditedQueryResolution(returnedPolicy.resolution);
setLastEditedQueryCritical(returnedPolicy.critical);
setLastEditedQueryPlatform(returnedPolicy.platform);
setLastEditedQueryLabelsIncludeAny(
returnedPolicy.labels_include_any || []
);
setLastEditedQueryLabelsExcludeAny(
returnedPolicy.labels_exclude_any || []
);
},
onError: (error) => handlePageError(error),
}
);
const hostIdFromURL = location.query.host_ids
? parseInt(location.query.host_ids as string, 10)
: null;
useQuery<IHostResponse, Error, IHost>(
["hostFromURL", hostIdFromURL, teamIdForApi],
() => hostAPI.loadHostDetails(hostIdFromURL as number),
{
enabled: !!hostIdFromURL && !queryParamHostsAdded,
select: (data: IHostResponse) => data.host,
onSuccess: (host) => {
setTargetedHosts((prevHosts) =>
prevHosts.filter((h) => h.id !== host.id).concat(host)
);
const targets = selectedTargets;
host.target_type = "hosts";
targets.push(host);
setSelectedTargets([...targets]);
if (!queryParamHostsAdded) {
setQueryParamHostsAdded(true);
}
router.replace(location.pathname);
},
}
);
// Updates title that shows up on browser tabs
useEffect(() => {
if (storedPolicy?.name) {
document.title = `Run ${storedPolicy.name} | Policies | ${DOCUMENT_TITLE_SUFFIX}`;
} else {
document.title = `Policies | ${DOCUMENT_TITLE_SUFFIX}`;
}
}, [location.pathname, storedPolicy?.name]);
const goToQueryEditor = useCallback(() => {
const path = policyId ? PATHS.EDIT_POLICY(policyId) : PATHS.NEW_POLICY;
router.push(getPathWithQueryParams(path, { fleet_id: teamIdForApi }));
}, [policyId, router, teamIdForApi]);
const renderScreen = () => {
const step1Props = {
baseClass,
selectedTargets,
targetedHosts,
targetedLabels,
targetedTeams,
targetsTotalCount,
goToQueryEditor,
goToRunQuery: () => setStep(LIVE_QUERY_STEPS[2]),
setSelectedTargets,
setTargetedHosts,
setTargetedLabels,
setTargetedTeams,
setTargetsTotalCount,
isLivePolicy: true,
};
const step2Props = {
selectedTargets,
storedPolicy,
setSelectedTargets,
goToQueryEditor,
targetsTotalCount,
};
switch (step) {
case LIVE_QUERY_STEPS[2]:
return <RunQuery {...step2Props} />;
default:
return <SelectTargets {...step1Props} />;
}
};
return (
<MainContent className={baseClass}>
<div className={`${baseClass}_wrapper`}>{renderScreen()}</div>
</MainContent>
);
};
export default LivePolicyPage;

View file

@ -1,8 +1,6 @@
.policy-page {
@include vertical-page-layout;
&__form {
@include vertical-form-layout;
.live-policy-page {
&_wrapper {
width: 100%;
}
&__results {
@ -12,34 +10,6 @@
min-height: 400px;
}
&__warning {
padding: $pad-medium;
font-size: $x-small;
color: $core-fleet-black;
background-color: #fff0b9;
border: 1px solid #f2c94c;
border-radius: $border-radius;
p {
margin: 0;
line-height: 20px;
}
}
&__observer-query-view {
width: 90%;
max-width: 1060px;
margin: 0 auto;
color: $core-fleet-black;
h1 {
font-size: $medium;
}
p {
font-size: $x-small;
}
}
.ace_content {
min-height: 500px !important;
}

View file

@ -0,0 +1 @@
export { default } from "./LivePolicyPage";

View file

@ -15,7 +15,7 @@ import { ICampaign, ICampaignState } from "interfaces/campaign";
import { IPolicy } from "interfaces/policy";
import { ITarget } from "interfaces/target";
import PolicyResults from "../components/PolicyResults";
import PolicyResults from "pages/policies/edit/components/PolicyResults";
interface IRunQueryProps {
storedPolicy: IPolicy | undefined;

View file

@ -42,10 +42,11 @@ import ManagePacksPage from "pages/packs/ManagePacksPage";
import ManagePoliciesPage from "pages/policies/ManagePoliciesPage";
import NoAccessPage from "pages/NoAccessPage";
import PackComposerPage from "pages/packs/PackComposerPage";
import PolicyDetailsPage from "pages/policies/PolicyDetailsPage";
import PolicyPage from "pages/policies/PolicyPage";
import PolicyDetailsPage from "pages/policies/details/PolicyDetailsPage";
import EditPolicyPage from "pages/policies/edit";
import QueryDetailsPage from "pages/queries/details/QueryDetailsPage";
import LiveQueryPage from "pages/queries/live/LiveQueryPage";
import LivePolicyPage from "pages/policies/live/LivePolicyPage";
import EditQueryPage from "pages/queries/edit/EditQueryPage";
import RegistrationPage from "pages/RegistrationPage";
import ResetPasswordPage from "pages/ResetPasswordPage";
@ -410,11 +411,15 @@ const routes = (
<IndexRedirect to="manage" />
<Route path="manage" component={ManagePoliciesPage} />
<Route component={AuthAnyMaintainerAnyAdminRoutes}>
<Route path="new" component={PolicyPage} />
<Route path="new">
<IndexRoute component={EditPolicyPage} />
<Route path="live" component={LivePolicyPage} />
</Route>
</Route>
<Route path=":id">
<IndexRoute component={PolicyDetailsPage} />
<Route path="edit" component={PolicyPage} />
<Route path="edit" component={EditPolicyPage} />
<Route path="live" component={LivePolicyPage} />
</Route>
</Route>
<Redirect from="profile" to="account" /> {/* deprecated URL */}

View file

@ -128,6 +128,8 @@ export default {
`${URL_PREFIX}/policies/${policyId}`,
EDIT_POLICY: (policyId: number): string =>
`${URL_PREFIX}/policies/${policyId}/edit`,
LIVE_POLICY: (policyId: number | null): string =>
`${URL_PREFIX}/policies/${policyId || "new"}/live`,
FORGOT_PASSWORD: `${URL_PREFIX}/login/forgot`,
MFA: `${URL_PREFIX}/login/mfa`,
NO_ACCESS: `${URL_PREFIX}/login/denied`,